feat: battle system, state manager enhancements, engine and demo updates
This commit is contained in:
159
ROADMAP.md
159
ROADMAP.md
@@ -144,6 +144,165 @@ QTE 场景和过渡/路由场景被过滤,子节点上浮一级。
|
|||||||
- [x] `src/components/StoryGallery.vue` — `isKeyMoment()` 三层逻辑 + `collectKeyTargets()` 扁平化非关键节点
|
- [x] `src/components/StoryGallery.vue` — `isKeyMoment()` 三层逻辑 + `collectKeyTargets()` 扁平化非关键节点
|
||||||
- [x] 验证:TypeScript + Vite build 通过
|
- [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<typeof setInterval> | 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)。
|
P0~P23 全部实现(除 P18)。详见 [CHANGELOG.md](CHANGELOG.md)。
|
||||||
|
|||||||
@@ -51,9 +51,9 @@ export class Engine {
|
|||||||
this.audioSystem = new AudioSystem()
|
this.audioSystem = new AudioSystem()
|
||||||
this.achievementSystem = new AchievementSystem()
|
this.achievementSystem = new AchievementSystem()
|
||||||
|
|
||||||
this.stateManager.onAfterApply = (vars) => {
|
this.stateManager.onAfterApply.add((vars) => {
|
||||||
this.achievementSystem.check(vars)
|
this.achievementSystem.check(vars)
|
||||||
}
|
})
|
||||||
|
|
||||||
this.videoManager.onTimeUpdate(this.onTimeUpdate)
|
this.videoManager.onTimeUpdate(this.onTimeUpdate)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export class StateManager {
|
|||||||
variables: Record<string, number> = {}
|
variables: Record<string, number> = {}
|
||||||
flags: Set<string> = new Set()
|
flags: Set<string> = new Set()
|
||||||
history: ChoiceRecord[] = []
|
history: ChoiceRecord[] = []
|
||||||
onAfterApply: ((variables: Record<string, number>) => void) | null = null
|
onAfterApply: Set<((variables: Record<string, number>) => void)> = new Set()
|
||||||
|
|
||||||
init(initialVars: Record<string, number>) {
|
init(initialVars: Record<string, number>) {
|
||||||
this.variables = { ...initialVars }
|
this.variables = { ...initialVars }
|
||||||
@@ -73,7 +73,7 @@ export class StateManager {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.onAfterApply?.(this.variables)
|
this.onAfterApply.forEach((cb) => cb(this.variables))
|
||||||
}
|
}
|
||||||
|
|
||||||
recordChoice(choice: ChoiceRecord) {
|
recordChoice(choice: ChoiceRecord) {
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export interface SceneNode {
|
|||||||
skippable?: boolean
|
skippable?: boolean
|
||||||
streamingUrl?: Record<string, string>
|
streamingUrl?: Record<string, string>
|
||||||
keyMoment?: boolean
|
keyMoment?: boolean
|
||||||
|
battleHUD?: BattleHUDEntry[]
|
||||||
|
battleResult?: BattleResultDef
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Choice {
|
export interface Choice {
|
||||||
@@ -166,3 +168,31 @@ export interface PlayerTreeNode {
|
|||||||
isGateway?: boolean
|
isGateway?: boolean
|
||||||
gatewayChapterId?: string
|
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[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -359,6 +359,22 @@
|
|||||||
"bgmVolume": 0.7,
|
"bgmVolume": 0.7,
|
||||||
"bgmCrossFade": 2.0,
|
"bgmCrossFade": 2.0,
|
||||||
"videoMuted": true,
|
"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": {
|
"qte": {
|
||||||
"triggerTime": 1.0,
|
"triggerTime": 1.0,
|
||||||
"prompt": "躲避飞来的石块!",
|
"prompt": "躲避飞来的石块!",
|
||||||
|
|||||||
11
src/App.vue
11
src/App.vue
@@ -13,6 +13,8 @@ import AchievementToast from '@/components/AchievementToast.vue'
|
|||||||
import AchievementPanel from '@/components/AchievementPanel.vue'
|
import AchievementPanel from '@/components/AchievementPanel.vue'
|
||||||
import AccessibilitySettings from '@/components/AccessibilitySettings.vue'
|
import AccessibilitySettings from '@/components/AccessibilitySettings.vue'
|
||||||
import StoryGallery from '@/components/StoryGallery.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 { useGameEngine } from '@/composables/useGameEngine'
|
||||||
import { useGameStore } from '@/stores/gameStore'
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
import { useFullscreen } from '@/composables/useFullscreen'
|
import { useFullscreen } from '@/composables/useFullscreen'
|
||||||
@@ -346,6 +348,10 @@ init()
|
|||||||
<Transition name="prompt-toast">
|
<Transition name="prompt-toast">
|
||||||
<div v-if="showPromptToast" class="prompt-toast">{{ promptToast }}</div>
|
<div v-if="showPromptToast" class="prompt-toast">{{ promptToast }}</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
<BattleHUD
|
||||||
|
v-if="store.currentScene?.battleHUD"
|
||||||
|
:entries="store.currentScene!.battleHUD"
|
||||||
|
/>
|
||||||
<div v-if="started && !store.gameEnded && store.choices.length === 0" class="top-bar" :class="{ hidden: !showTopBar }">
|
<div v-if="started && !store.gameEnded && store.choices.length === 0" class="top-bar" :class="{ hidden: !showTopBar }">
|
||||||
<button class="top-btn" @click="toggleFullscreen" :title="t('ui.fullscreen')">⛶</button>
|
<button class="top-btn" @click="toggleFullscreen" :title="t('ui.fullscreen')">⛶</button>
|
||||||
<button class="top-btn" @click="showPauseMenu = true" :title="t('ui.menu')">≡</button>
|
<button class="top-btn" @click="showPauseMenu = true" :title="t('ui.menu')">≡</button>
|
||||||
@@ -361,6 +367,11 @@ init()
|
|||||||
@skip="handleSkip"
|
@skip="handleSkip"
|
||||||
@speed-change="handleSpeedChange"
|
@speed-change="handleSpeedChange"
|
||||||
/>
|
/>
|
||||||
|
<BattleResult
|
||||||
|
v-if="store.showBattleResult"
|
||||||
|
:result="store.battleResultData"
|
||||||
|
@continue="store.setShowBattleResult(false)"
|
||||||
|
/>
|
||||||
<MainMenu
|
<MainMenu
|
||||||
v-if="!started || store.gameEnded"
|
v-if="!started || store.gameEnded"
|
||||||
:show-resume="!store.gameEnded && hasAutoSave"
|
:show-resume="!store.gameEnded && hasAutoSave"
|
||||||
|
|||||||
147
src/components/BattleHUD.vue
Normal file
147
src/components/BattleHUD.vue
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="battle-hud" v-if="entries.length > 0">
|
||||||
|
<div class="hud-entry" v-for="entry in entries" :key="entry.label">
|
||||||
|
<img v-if="entry.portrait" :src="entry.portrait" class="hud-portrait" />
|
||||||
|
<div class="hud-stats">
|
||||||
|
<div class="hud-name">{{ t(entry.labelKey || entry.label) }}</div>
|
||||||
|
<div class="hud-stat" v-for="s in entry.stats" :key="s.variable">
|
||||||
|
<span class="stat-label">{{ t(s.labelKey || s.label) }}</span>
|
||||||
|
<template v-if="statStyle(s) === 'number'">
|
||||||
|
<span class="stat-value">{{ statValue(s.variable) }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="stat-bar-bg">
|
||||||
|
<div
|
||||||
|
class="stat-bar-fill"
|
||||||
|
:class="barClass(barPercent(statValue(s.variable), s.max || 1))"
|
||||||
|
:style="{ width: barPercent(statValue(s.variable), s.max || 1) + '%' }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="stat-value">{{ statValue(s.variable) }}/{{ s.max }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.battle-hud {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
left: 20px;
|
||||||
|
z-index: 25;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-portrait {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #ddd;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999;
|
||||||
|
min-width: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-bar-bg {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: #4caf50;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-bar-fill.warning {
|
||||||
|
background: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-bar-fill.danger {
|
||||||
|
background: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #ddd;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 50px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
114
src/components/BattleResult.vue
Normal file
114
src/components/BattleResult.vue
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { BattleResultDef } from '@engine/types'
|
||||||
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
|
import { useI18n } from '@/composables/useI18n'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
result: BattleResultDef
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
continue: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const store = useGameStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
function statValue(variable: string): number {
|
||||||
|
return store.variable(variable)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="battle-result-overlay" @click.self="emit('continue')" @keydown.escape="emit('continue')">
|
||||||
|
<div class="battle-result-panel">
|
||||||
|
<h2 class="result-title">{{ t(result.titleKey || result.title) }}</h2>
|
||||||
|
|
||||||
|
<div class="result-stats">
|
||||||
|
<div class="result-stat" v-for="s in result.stats" :key="s.variable">
|
||||||
|
<span class="rstat-label">{{ t(s.labelKey || s.label) }}</span>
|
||||||
|
<span class="rstat-value">
|
||||||
|
{{ statValue(s.variable) }}
|
||||||
|
<span v-if="s.max !== undefined"> / {{ s.max }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="result-continue" @click="emit('continue')">{{ t('ui.continue') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.battle-result-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-result-panel {
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 36px 40px;
|
||||||
|
min-width: 340px;
|
||||||
|
max-width: 440px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #ffc107;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-stat {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rstat-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rstat-value {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-continue {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 12px 48px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-continue:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -12,6 +12,10 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
let lastThumbnail: string | undefined
|
let lastThumbnail: string | undefined
|
||||||
|
|
||||||
|
engine.stateManager.onAfterApply.add((vars) => {
|
||||||
|
store.syncVariables(vars)
|
||||||
|
})
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
;(window as any).__sm = engine.stateManager
|
;(window as any).__sm = engine.stateManager
|
||||||
;(window as any).__store = store
|
;(window as any).__store = store
|
||||||
@@ -42,6 +46,10 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide
|
|||||||
store.clearTimer()
|
store.clearTimer()
|
||||||
store.clearHotspots()
|
store.clearHotspots()
|
||||||
store.setIsImageScene(scene.type === 'image')
|
store.setIsImageScene(scene.type === 'image')
|
||||||
|
if (scene.battleResult) {
|
||||||
|
store.setBattleResult(scene.battleResult)
|
||||||
|
}
|
||||||
|
store.syncVariables(engine.stateManager.variables)
|
||||||
saveGame(0)
|
saveGame(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,8 @@
|
|||||||
"qualityAuto": "Auto",
|
"qualityAuto": "Auto",
|
||||||
"quality1080p": "1080p 2.5Mbps",
|
"quality1080p": "1080p 2.5Mbps",
|
||||||
"quality720p": "720p 2Mbps",
|
"quality720p": "720p 2Mbps",
|
||||||
"quality480p": "480p 0.8Mbps"
|
"quality480p": "480p 0.8Mbps",
|
||||||
|
"continue": "Continue",
|
||||||
|
"toMenu": "Back to Menu"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,6 +41,8 @@
|
|||||||
"qualityAuto": "自动",
|
"qualityAuto": "自动",
|
||||||
"quality1080p": "超清 320KB/s",
|
"quality1080p": "超清 320KB/s",
|
||||||
"quality720p": "高清 256KB/s",
|
"quality720p": "高清 256KB/s",
|
||||||
"quality480p": "标清 100KB/s"
|
"quality480p": "标清 100KB/s",
|
||||||
|
"continue": "继续",
|
||||||
|
"toMenu": "返回菜单"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,6 +45,9 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
const antiMistap = ref(localStorage.getItem('antiMistap') !== 'false')
|
const antiMistap = ref(localStorage.getItem('antiMistap') !== 'false')
|
||||||
const pauseEnabled = ref(localStorage.getItem('pauseEnabled') !== 'false')
|
const pauseEnabled = ref(localStorage.getItem('pauseEnabled') !== 'false')
|
||||||
const showSettings = ref(false)
|
const showSettings = ref(false)
|
||||||
|
const showBattleResult = ref(false)
|
||||||
|
const battleResultData = ref<any>(null)
|
||||||
|
const variables = ref<Record<string, number>>({})
|
||||||
const preferredQuality = ref(localStorage.getItem('preferredQuality') || '')
|
const preferredQuality = ref(localStorage.getItem('preferredQuality') || '')
|
||||||
const introVideo = ref('')
|
const introVideo = ref('')
|
||||||
const menuVideo = 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 setPauseEnabled(v: boolean) { pauseEnabled.value = v; localStorage.setItem('pauseEnabled', String(v)) }
|
||||||
function setShowSettings(v: boolean) { showSettings.value = v }
|
function setShowSettings(v: boolean) { showSettings.value = v }
|
||||||
|
|
||||||
|
function variable(name: string): number {
|
||||||
|
return variables.value[name] ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncVariables(vars: Record<string, number>) {
|
||||||
|
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 setPreferredQuality(q: string) { preferredQuality.value = q; localStorage.setItem('preferredQuality', q) }
|
||||||
|
|
||||||
function setIntroVideo(url: string) { introVideo.value = url }
|
function setIntroVideo(url: string) { introVideo.value = url }
|
||||||
@@ -226,6 +244,7 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
storyLocales,
|
storyLocales,
|
||||||
subFontSize, subBgAlpha, qteTimeRelax, qteSingleKey, antiMistap, pauseEnabled,
|
subFontSize, subBgAlpha, qteTimeRelax, qteSingleKey, antiMistap, pauseEnabled,
|
||||||
showSettings, introVideo, menuVideo,
|
showSettings, introVideo, menuVideo,
|
||||||
|
showBattleResult, battleResultData, variables,
|
||||||
preferredQuality,
|
preferredQuality,
|
||||||
setScene, setChoices, clearChoices, setGameEnded,
|
setScene, setChoices, clearChoices, setGameEnded,
|
||||||
setTimer, clearTimer, setSaves,
|
setTimer, clearTimer, setSaves,
|
||||||
@@ -239,6 +258,7 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
setStoryLocales,
|
setStoryLocales,
|
||||||
setSubFontSize, setSubBgAlpha, setQteTimeRelax, setQteSingleKey, setAntiMistap, setPauseEnabled,
|
setSubFontSize, setSubBgAlpha, setQteTimeRelax, setQteSingleKey, setAntiMistap, setPauseEnabled,
|
||||||
setShowSettings, setIntroVideo, setMenuVideo,
|
setShowSettings, setIntroVideo, setMenuVideo,
|
||||||
|
variable, setBattleResult, setShowBattleResult, syncVariables,
|
||||||
setPreferredQuality,
|
setPreferredQuality,
|
||||||
dump,
|
dump,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user