Compare commits

...

9 Commits

Author SHA1 Message Date
zwt13703 bab629a29e 战斗表现优化:动画、伤害飘字、暴击/MP/治疗样式
- 添加敌我攻击/受击动画与命中闪烁
- 敌人支持独立攻击时长、命中延迟配置
- 伤害飘字增加随机滚动、跟随、淡出效果
- 实现暴击、MP伤害、治疗的飘字颜色区分
2026-04-11 10:02:38 +08:00
zwt13703 3eeba28daa 1. 网格化俯视战场(地面棋盘 + Grid 表现)。
2. 指令目标选择:攻击/技能 不再立即结算,先进入“选择目标”状态。
  3. 点击敌方单位才执行对应指令(回合制目标确认)。
  4. 范围提示环:攻击和技能分别显示不同范围/颜色提示。
  5. HUD 提示文案:会显示“请选择目标(点击敌方单位)”。
2026-04-11 08:34:44 +08:00
zwt13703 f1d5b7e047 1. 把战斗从纯面板改为 Three.js 顶视战场(固定俯视相机 + 战场地形 + 我方/敌方单位)。
2. 加入回合表现反馈(当前行动方光圈高亮、受击闪烁、敌方血量影响体型)。
  3. 保留原回合指令 HUD(攻击/技能/防御/道具)和日志、结算按钮。
  4. 增加战斗资源生命周期管理(进入战斗初始化,退出战斗释放 WebGL 资源)。
2026-04-11 08:08:36 +08:00
zwt13703 affc6ed059 1. 接任务:走到 NPC(左下区域)附近,按 E 接取“平原讨伐令”。
2. 做任务:探索遇敌并战斗,胜利后自动累计击败数。
  3. 交任务:击败达到目标后,回 NPC 按 E 交付并领取奖励(药草 + MP)。
2026-04-11 07:58:39 +08:00
zwt13703 c2a3283c54 1. 数据驱动战斗配置:敌人和技能参数从逻辑代码拆到配置文件。
2. 场景状态机:新增 sceneMode(explore/battleTransition/battle)。
  3. 战斗切入过渡:遇敌先进入短过场,再切到战斗界面。
  4. 探索锁定规则升级:只在 explore 状态允许移动。
2026-04-11 07:46:52 +08:00
zwt13703 f2da55321f 1. 数据驱动战斗配置:敌人和技能参数从逻辑代码拆到配置文件。
2. 场景状态机:新增 sceneMode(explore/battleTransition/battle)。
  3. 战斗切入过渡:遇敌先进入短过场,再切到战斗界面。
  4. 探索锁定规则升级:只在 explore 状态允许移动。
2026-04-11 07:46:46 +08:00
zwt13703 08d4c1349e 1. 真正战斗触发:探索时遇敌会进入战斗,不再是占位提示。
2. 回合制战斗 MVP:攻击/技能/防御/道具 四个指令可用,含敌方回合与伤害结算。
  3. 胜负结算与返回:战斗结束后可“返回探索”继续移动。
  4. 探索锁定:战斗中会锁定场景移动,避免状态冲突。
  5. 状态联动:状态面板增加药草数量与“探索中/战斗中”。
2026-04-11 07:39:05 +08:00
zwt13703 823e4c5090 1. 角色移动:WASD/方向键 控制移动。
2. 碰撞限制:地图边界 + 障碍物碰撞阻挡。
  3. 区域切换:根据位置切换区域名(训练平原/东侧坡道/西侧荒地)。
  4. 事件占位:传送点接触提示、随机遇敌计量与触发提示(为后续战斗切入预留)。
  5. UI联动:状态面板实时显示坐标、移速、区域、遇敌计量和提示信息。
  6. 构建验证:npm run build 已通过。
2026-04-11 00:43:13 +08:00
zwt13703 f4a9faab18 1. 初始化 Vue3 + TypeScript + Vite 工程并接入 Three.js。
2. 搭了一个最小 3D 场景(地面、灯光、角色方块、循环渲染)。
  3. 建了基础目录分层:core/game/ui/data。
  4. 加了一个简单 RPG 状态面板(HP/MP/Lv/区域)。
  5. 依赖安装并通过构建验证(npm run build 成功)。
2026-04-11 00:36:38 +08:00
24 changed files with 3386 additions and 0 deletions

24
.gitignore vendored
View File

@ -190,3 +190,27 @@ replay_pid*
# Go workspace file
go.work
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -0,0 +1,121 @@
# 任务执行摘要
## 会话 ID: 1775837013
- [2026-04-11 00:03:33]
- **执行原因**: 用户咨询是否可用 Node + TypeScript + Vue + WebGL 实现 PSP《火焰气息3》风格 RPG。
- **执行过程**:
1. 评估目标玩法与技术栈匹配度回合制战斗、场景探索、UI 系统、存档等)。
2. 给出可实现性结论、核心风险与分阶段落地建议。
3. 按项目规范记录本次会话任务摘要到 docs/tasks。
- **执行结果**: 结论为可实现,建议以 Web 平台复刻“风格与机制”而非原作内容,并采用分阶段 MVP 路线推进。
# 任务执行摘要
## 会话 ID: 1775837411
- [2026-04-11 00:10:11]
- **执行原因**: 用户要求先做《火焰气息3》风格 RPG 的简要实施规划。
- **执行过程**:
1. 按技术栈Node/TypeScript/Vue/WebGL拆分客户端、渲染、数据驱动、工具链与存档模块。
2. 设计分阶段里程碑MVP 到可扩展版本)与每阶段验收标准。
3. 汇总风险与优先级,给出先做什么、后做什么的执行顺序。
- **执行结果**: 形成一套可直接启动开发的简明路线图,优先保证可玩闭环,再逐步增加内容复杂度。
# 任务执行摘要
## 会话 ID: 1775837632
- [2026-04-11 00:13:52]
- **执行原因**: 用户要求生成“计划清单.md”用于 BOF3 风格 RPG 项目推进。
- **执行过程**:
1. 依据既定路线图整理里程碑与勾选式任务。
2. 在仓库根目录创建计划清单.md覆盖初始化、探索、战斗、成长存档、打磨与验收标准。
3. 补充持续风险跟踪项,便于后续迭代管理。
- **执行结果**: 已生成可直接执行的《计划清单.md》可作为当前项目任务看板使用。
# 任务执行摘要
## 会话 ID: 1775838708
- [2026-04-11 00:31:48]
- **执行原因**: 用户要求“先帮忙做一点”,期望先落地 BOF3 风格 RPG 的技术基础。
- **执行过程**:
1. 初始化 Vue3 + TypeScript + Vite 项目骨架并同步到仓库。
2. 新增 Three.js 最小场景组件、游戏循环模块、状态面板与基础配置文件。
3. 安装依赖并修复 TypeScript 构建问题,完成生产构建验证。
- **执行结果**: 项目已可运行并可构建,页面可显示基础 3D 场景与 RPG 调试面板,作为后续探索/战斗系统开发起点。
# 任务执行摘要
## 会话 ID: 1775839250
- [2026-04-11 00:40:50]
- **执行原因**: 用户要求“继续完成”,希望在现有骨架上推进可玩功能。
- **执行过程**:
1. 新增输入系统与共享游戏状态,支持键盘移动与状态同步展示。
2. 扩展 Three.js 场景逻辑,加入角色移动、边界与障碍碰撞、区域切换、传送点占位和随机遇敌计量。
3. 更新状态面板为实时数据显示,并执行构建验证确保可编译运行。
- **执行结果**: 已完成探索层 MVP玩家可在 3D 场景中移动并获得基础 RPG 反馈,项目继续可在此基础上对接战斗系统。
# 任务执行摘要
## 会话 ID: 1775840072
- [2026-04-11 00:54:32]
- **执行原因**: 用户继续要求推进开发,目标是从探索占位过渡到可执行的战斗流程。
- **执行过程**:
1. 扩展全局游戏状态,加入战斗状态机、行动指令与敌方回合逻辑。
2. 新增战斗面板组件,提供攻击/技能/防御/道具操作与战斗日志展示。
3. 将场景遇敌逻辑接入战斗入口,并在战斗期间锁定探索移动;完成构建验证。
- **执行结果**: 已形成“探索移动 -> 遇敌触发 -> 回合战斗 -> 结算返回探索”的可玩闭环 MVP。
# 任务执行摘要
## 会话 ID: 1775864637
- [2026-04-11 07:43:57]
- **执行原因**: 用户继续要求推进可玩版本,目标提升战斗系统可扩展性与切场体验。
- **执行过程**:
1. 新增 battleData 配置文件,将敌人与技能参数改为数据驱动。
2. 重构 game state引入 sceneModeexplore/battleTransition/battle并加入战斗切入过渡状态机。
3. 新增 TransitionOverlay 过渡组件并接入 App完成探索-切场-战斗流程联动与构建验证。
- **执行结果**: 已完成数据驱动战斗基础与切场过渡表现,后续可直接扩充敌人/技能配置并接任务或场景系统。
# 任务执行摘要
## 会话 ID: 1775865108
- [2026-04-11 07:51:48]
- **执行原因**: 用户继续要求推进,目标增加 NPC 交互与任务链闭环。
- **执行过程**:
1. 扩展输入系统,增加按键单次触发能力(用于 E 键交互)。
2. 在全局状态中加入任务模块,实现接取、战斗击杀计数、任务完成与奖励交付逻辑。
3. 在探索场景添加 NPC 模型与互动提示,接入 E 键对话,并更新状态面板展示任务进度。
- **执行结果**: 已形成第一条任务链闭环(接任务 -> 打怪累积 -> 回 NPC 交付),并通过构建验证。
# 任务执行摘要
## 会话 ID: 1775865869
- [2026-04-11 08:04:29]
- **执行原因**: 用户要求将战斗系统改为俯视角 RPG 回合战斗场景风格。
- **执行过程**:
1. 重构 BattlePanel接入 Three.js 俯视战场(相机、地形、单位、回合高亮、受击反馈)。
2. 保留并整合原有回合指令 HUD攻击/技能/防御/道具)与日志结算区。
3. 增加战斗场景资源生命周期管理(进入初始化、退出释放),并完成构建验证。
- **执行结果**: 战斗已从纯 UI 面板升级为俯视角战斗场景形成“3D 战场 + 回合指令 HUD”的可玩表现。
# 任务执行摘要
## 会话 ID: 1775866460
- [2026-04-11 08:14:20]
- **执行原因**: 用户继续要求优化战斗体验,目标增加俯视战斗的战术交互感。
- **执行过程**:
1. 在 BattlePanel 中加入网格化战场表现与俯视镜头,增强回合战斗场景感。
2. 增加目标选择流程:攻击/技能进入待选目标状态,点击敌方单位后执行指令。
3. 新增技能/攻击范围环提示、HUD 引导文案和资源释放逻辑,完成构建验证。
- **执行结果**: 战斗场景已具备基础战术交互(目标选择+范围提示),体验更接近俯视角 RPG 回合战斗。
# 任务执行摘要
## 会话 ID: 1775868509
- [2026-04-11 08:48:29]
- **执行原因**: 用户要求补齐战斗演出:攻击/受击动画、敌方不同攻击节奏、伤害飘字滚动与颜色区分。
- **执行过程**:
1. 扩展敌人配置新增攻击总时长、命中延迟、MP 打击概率等参数,支持不同敌人差异化反馈时序。
2. 重构战斗状态机为事件驱动attack/hit/popup将命中与结算从瞬时执行改为时序执行并加入暴击、MP 伤害与恢复事件。
3. 在俯视战场中接入攻击位移动画、受击闪烁动画和飘字系统(随机滚动到最终值、暴击/MP/治疗颜色区分)。
- **执行结果**: 已实现完整战斗演出链路,反馈时机随敌人攻击动画参数变化,并通过构建验证。

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>bof3-scaffold</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1319
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "bof3-demo",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"three": "^0.180.0",
"vue": "^3.5.32"
},
"devDependencies": {
"@types/node": "^24.12.2",
"@types/three": "^0.183.1",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
"vite": "^8.0.4",
"vue-tsc": "^3.2.6"
}
}

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View File

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

23
src/App.vue Normal file
View File

@ -0,0 +1,23 @@
<script setup lang="ts">
import ThreeScene from './game/ThreeScene.vue'
import BattlePanel from './ui/BattlePanel.vue'
import StatusPanel from './ui/StatusPanel.vue'
import TransitionOverlay from './ui/TransitionOverlay.vue'
import { gameConfig } from './data/gameConfig'
</script>
<template>
<div class="app-shell">
<header class="top-bar">
<h1>{{ gameConfig.title }}</h1>
<p>{{ gameConfig.tagline }}</p>
</header>
<main class="content">
<StatusPanel />
<ThreeScene />
</main>
<TransitionOverlay />
<BattlePanel />
</div>
</template>

47
src/core/input.ts Normal file
View File

@ -0,0 +1,47 @@
export class KeyboardInput {
private readonly pressed = new Set<string>()
private readonly justPressed = new Set<string>()
private readonly onKeyDown = (event: KeyboardEvent) => {
if (!this.pressed.has(event.code)) {
this.justPressed.add(event.code)
}
this.pressed.add(event.code)
}
private readonly onKeyUp = (event: KeyboardEvent) => {
this.pressed.delete(event.code)
}
start() {
window.addEventListener('keydown', this.onKeyDown)
window.addEventListener('keyup', this.onKeyUp)
}
stop() {
window.removeEventListener('keydown', this.onKeyDown)
window.removeEventListener('keyup', this.onKeyUp)
this.pressed.clear()
this.justPressed.clear()
}
getMoveAxis() {
let x = 0
let z = 0
if (this.pressed.has('KeyW') || this.pressed.has('ArrowUp')) z -= 1
if (this.pressed.has('KeyS') || this.pressed.has('ArrowDown')) z += 1
if (this.pressed.has('KeyA') || this.pressed.has('ArrowLeft')) x -= 1
if (this.pressed.has('KeyD') || this.pressed.has('ArrowRight')) x += 1
return { x, z }
}
consumePressed(code: string) {
const hit = this.justPressed.has(code)
if (hit) {
this.justPressed.delete(code)
}
return hit
}
}

26
src/core/loop.ts Normal file
View File

@ -0,0 +1,26 @@
export class GameLoop {
private frameId = 0
private lastTime = 0
private readonly tick: (dt: number) => void
constructor(tick: (dt: number) => void) {
this.tick = tick
}
start() {
this.lastTime = performance.now()
const run = (now: number) => {
const dt = Math.min((now - this.lastTime) / 1000, 0.05)
this.lastTime = now
this.tick(dt)
this.frameId = requestAnimationFrame(run)
}
this.frameId = requestAnimationFrame(run)
}
stop() {
cancelAnimationFrame(this.frameId)
}
}

65
src/data/battleData.ts Normal file
View File

@ -0,0 +1,65 @@
export interface EnemyDef {
id: string
name: string
hp: number
minAtk: number
maxAtk: number
attackAnimMs: number
hitDelayMs: number
mpStrikeChance: number
rewardExp: [number, number]
}
export interface SkillDef {
id: string
name: string
mpCost: number
minDamage: number
maxDamage: number
}
export const enemies: EnemyDef[] = [
{
id: 'plain-wolf',
name: '平原狼',
hp: 65,
minAtk: 10,
maxAtk: 18,
attackAnimMs: 540,
hitDelayMs: 280,
mpStrikeChance: 0.05,
rewardExp: [12, 20],
},
{
id: 'cave-slime',
name: '洞穴史莱姆',
hp: 72,
minAtk: 9,
maxAtk: 16,
attackAnimMs: 860,
hitDelayMs: 520,
mpStrikeChance: 0.35,
rewardExp: [14, 22],
},
{
id: 'mountain-lizard',
name: '山地蜥蜴',
hp: 78,
minAtk: 11,
maxAtk: 19,
attackAnimMs: 680,
hitDelayMs: 360,
mpStrikeChance: 0.15,
rewardExp: [16, 24],
},
]
export const skills: SkillDef[] = [
{
id: 'flame',
name: '火焰术',
mpCost: 6,
minDamage: 24,
maxDamage: 34,
},
]

4
src/data/gameConfig.ts Normal file
View File

@ -0,0 +1,4 @@
export const gameConfig = {
title: 'Breath of Fire-like RPG Demo',
tagline: '里程碑 7: 攻击/受击动画 + 时序反馈 + 飘字系统',
}

272
src/game/ThreeScene.vue Normal file
View File

@ -0,0 +1,272 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import * as THREE from 'three'
import type { Mesh, Object3D } from 'three'
import { GameLoop } from '../core/loop'
import { KeyboardInput } from '../core/input'
import { interactQuestNpc, startBattle, useGameState } from './state'
const root = ref<HTMLDivElement | null>(null)
const gameState = useGameState()
let renderer: THREE.WebGLRenderer | null = null
let camera: THREE.PerspectiveCamera | null = null
let scene: THREE.Scene | null = null
let resizeObserver: ResizeObserver | null = null
let loop: GameLoop | null = null
let keyboard: KeyboardInput | null = null
const playerRadius = 0.45
const worldBound = 7.2
let encounterTarget = 45
const obstacleDefs = [
{ x: -2.4, z: 1.5, radius: 0.9 },
{ x: 1.8, z: -2.2, radius: 1.1 },
{ x: 3.1, z: 2.7, radius: 0.8 },
]
const canMoveTo = (nextX: number, nextZ: number) => {
if (Math.abs(nextX) > worldBound || Math.abs(nextZ) > worldBound) {
return false
}
for (const obstacle of obstacleDefs) {
const dx = nextX - obstacle.x
const dz = nextZ - obstacle.z
const minDistance = playerRadius + obstacle.radius
if (dx * dx + dz * dz < minDistance * minDistance) {
return false
}
}
return true
}
const updateArea = (x: number) => {
if (x > 2.5) {
gameState.area = '东侧坡道'
return
}
if (x < -2.5) {
gameState.area = '西侧荒地'
return
}
gameState.area = '训练平原'
}
const initScene = () => {
if (!root.value) return
scene = new THREE.Scene()
scene.background = new THREE.Color('#121a1f')
camera = new THREE.PerspectiveCamera(60, 1, 0.1, 100)
camera.position.set(5, 5, 7)
camera.lookAt(0, 0, 0)
renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
root.value.appendChild(renderer.domElement)
const ambient = new THREE.AmbientLight('#89a2ff', 0.45)
scene.add(ambient)
const sun = new THREE.DirectionalLight('#ffe8ad', 1.4)
sun.position.set(4, 8, 3)
scene.add(sun)
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(16, 16),
new THREE.MeshStandardMaterial({ color: '#1f2a33' }),
)
ground.rotation.x = -Math.PI / 2
scene.add(ground)
const hero = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshStandardMaterial({ color: '#5bc8af' }),
)
hero.position.y = 0.55
scene.add(hero)
const portal = new THREE.Mesh(
new THREE.TorusGeometry(0.75, 0.12, 16, 36),
new THREE.MeshStandardMaterial({ color: '#5f9df7', emissive: '#27466f', emissiveIntensity: 0.65 }),
)
portal.position.set(5.3, 0.8, 5.3)
portal.rotation.x = Math.PI / 2
scene.add(portal)
const npc = new THREE.Mesh(
new THREE.CapsuleGeometry(0.35, 0.8, 4, 8),
new THREE.MeshStandardMaterial({ color: '#f4b45b' }),
)
npc.position.set(-5.2, 0.8, -4.8)
scene.add(npc)
const npcMark = new THREE.Mesh(
new THREE.SphereGeometry(0.18, 16, 16),
new THREE.MeshStandardMaterial({ color: '#ffe071', emissive: '#6c5617', emissiveIntensity: 0.7 }),
)
npcMark.position.set(-5.2, 1.95, -4.8)
scene.add(npcMark)
for (const obstacle of obstacleDefs) {
const stone = new THREE.Mesh(
new THREE.CylinderGeometry(obstacle.radius, obstacle.radius, 1.2, 16),
new THREE.MeshStandardMaterial({ color: '#4d5d69' }),
)
stone.position.set(obstacle.x, 0.6, obstacle.z)
scene.add(stone)
}
const grid = new THREE.GridHelper(16, 16, '#4e6878', '#24323c')
scene.add(grid)
const resize = () => {
if (!root.value || !renderer || !camera) return
const width = root.value.clientWidth
const height = root.value.clientHeight
camera.aspect = width / height
camera.updateProjectionMatrix()
renderer.setSize(width, height)
}
resizeObserver = new ResizeObserver(resize)
resizeObserver.observe(root.value)
resize()
keyboard = new KeyboardInput()
keyboard.start()
const cameraOffset = new THREE.Vector3(4.5, 5.2, 6.2)
loop = new GameLoop((dt) => {
if (gameState.sceneMode !== 'explore') {
if (renderer && scene && camera) {
renderer.render(scene, camera)
}
return
}
const axis = keyboard?.getMoveAxis() ?? { x: 0, z: 0 }
const hasMoveInput = axis.x !== 0 || axis.z !== 0
if (hasMoveInput) {
const len = Math.hypot(axis.x, axis.z)
const dirX = axis.x / len
const dirZ = axis.z / len
const nextX = hero.position.x + dirX * gameState.moveSpeed * dt
const nextZ = hero.position.z + dirZ * gameState.moveSpeed * dt
if (canMoveTo(nextX, nextZ)) {
hero.position.x = nextX
hero.position.z = nextZ
const movedDistance = Math.hypot(dirX * gameState.moveSpeed * dt, dirZ * gameState.moveSpeed * dt)
gameState.encounterMeter += movedDistance * 13
if (gameState.encounterMeter >= encounterTarget) {
gameState.encounterMeter = 0
encounterTarget = 35 + Math.random() * 30
startBattle('你在平原中前进')
}
} else {
gameState.notice = '前方有障碍,无法通过'
}
hero.rotation.y = Math.atan2(axis.x, axis.z)
}
portal.rotation.z += dt
npcMark.position.y = 1.95 + Math.sin(performance.now() * 0.004) * 0.12
if (hero.position.distanceTo(portal.position) < 1.1) {
gameState.notice = '进入传送点(占位):可切换场景'
}
const npcDistance = hero.position.distanceTo(npc.position)
if (npcDistance < 1.5) {
gameState.notice = '按 E 与 NPC 对话'
if (keyboard?.consumePressed('KeyE')) {
interactQuestNpc()
}
}
updateArea(hero.position.x)
gameState.positionText = `(${hero.position.x.toFixed(1)}, ${hero.position.z.toFixed(1)})`
if (camera) {
const cameraTarget = hero.position.clone().add(cameraOffset)
camera.position.lerp(cameraTarget, 0.06)
camera.lookAt(hero.position.x, 0.5, hero.position.z)
}
if (renderer && scene && camera) {
renderer.render(scene, camera)
}
})
loop.start()
}
const disposeScene = () => {
loop?.stop()
keyboard?.stop()
resizeObserver?.disconnect()
if (root.value && renderer) {
root.value.removeChild(renderer.domElement)
}
scene?.traverse((object: Object3D) => {
const mesh = object as Mesh
if (mesh.geometry) mesh.geometry.dispose()
const material = mesh.material
if (Array.isArray(material)) {
material.forEach((m) => m.dispose())
} else if (material) {
material.dispose()
}
})
renderer?.dispose()
renderer = null
camera = null
scene = null
resizeObserver = null
loop = null
keyboard = null
}
onMounted(initScene)
onBeforeUnmount(disposeScene)
</script>
<template>
<section class="scene-wrap">
<div ref="root" class="scene-canvas" />
</section>
</template>
<style scoped>
.scene-wrap {
min-height: 420px;
border-radius: 14px;
overflow: hidden;
border: 1px solid #2c3d49;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
}
.scene-canvas {
width: 100%;
height: 100%;
min-height: 420px;
}
</style>

451
src/game/state.ts Normal file
View File

@ -0,0 +1,451 @@
import { reactive } from 'vue'
import { enemies, skills, type EnemyDef } from '../data/battleData'
export type BattleAction = 'attack' | 'skill' | 'defend' | 'item'
export type BattleTurn = 'player' | 'enemy' | 'resolved'
export type SceneMode = 'explore' | 'battleTransition' | 'battle'
type QuestStatus = 'not_started' | 'in_progress' | 'completed' | 'turned_in'
type UnitSide = 'player' | 'enemy'
type PopupValueType = 'hp' | 'mp' | 'heal'
export interface BattleFxEvent {
id: number
kind: 'attack' | 'hit' | 'popup'
actor?: UnitSide
target?: UnitSide
value?: number
valueType?: PopupValueType
crit?: boolean
durationMs?: number
hitDelayMs?: number
}
interface QuestState {
id: string
title: string
status: QuestStatus
targetKills: number
currentKills: number
rewardPotions: number
}
interface BattleState {
active: boolean
turn: BattleTurn
enemyName: string
enemyHp: number
enemyMaxHp: number
guarding: boolean
victory: boolean | null
message: string
log: string[]
enemyData: EnemyDef | null
fxEvents: BattleFxEvent[]
nextFxId: number
}
interface PlayerState {
hp: number
maxHp: number
mp: number
maxMp: number
potions: number
}
interface GameState {
hp: string
mp: string
level: number
area: string
positionText: string
moveSpeed: number
encounterMeter: number
notice: string
sceneMode: SceneMode
player: PlayerState
battle: BattleState
quest: QuestState
}
const state = reactive<GameState>({
hp: '120 / 120',
mp: '38 / 38',
level: 1,
area: '训练平原',
positionText: '(0.0, 0.0)',
moveSpeed: 3.2,
encounterMeter: 0,
notice: '按 WASD 或方向键移动',
sceneMode: 'explore',
player: {
hp: 120,
maxHp: 120,
mp: 38,
maxMp: 38,
potions: 3,
},
battle: {
active: false,
turn: 'player',
enemyName: '',
enemyHp: 0,
enemyMaxHp: 0,
guarding: false,
victory: null,
message: '',
log: [],
enemyData: null,
fxEvents: [],
nextFxId: 1,
},
quest: {
id: 'wolf-subjugation',
title: '平原讨伐令',
status: 'not_started',
targetKills: 3,
currentKills: 0,
rewardPotions: 2,
},
})
let battleEnterTimer = 0
const battleTimers: number[] = []
const scheduleBattle = (delayMs: number, task: () => void) => {
const id = window.setTimeout(() => {
const idx = battleTimers.indexOf(id)
if (idx >= 0) battleTimers.splice(idx, 1)
task()
}, delayMs)
battleTimers.push(id)
}
const clearBattleTimers = () => {
for (const id of battleTimers) {
clearTimeout(id)
}
battleTimers.length = 0
}
const addBattleLog = (line: string) => {
state.battle.log.push(line)
if (state.battle.log.length > 8) {
state.battle.log.shift()
}
}
const emitFx = (event: Omit<BattleFxEvent, 'id'>) => {
state.battle.fxEvents.push({
id: state.battle.nextFxId++,
...event,
})
if (state.battle.fxEvents.length > 120) {
state.battle.fxEvents.splice(0, state.battle.fxEvents.length - 120)
}
}
const roll = (min: number, max: number) => {
return Math.floor(Math.random() * (max - min + 1)) + min
}
const pickRandom = <T>(arr: T[]) => arr[roll(0, arr.length - 1)]
const updateDerivedBars = () => {
state.hp = `${Math.max(0, state.player.hp)} / ${state.player.maxHp}`
state.mp = `${Math.max(0, state.player.mp)} / ${state.player.maxMp}`
}
const updateQuestProgressOnVictory = () => {
if (state.quest.status !== 'in_progress') {
return
}
state.quest.currentKills += 1
if (state.quest.currentKills >= state.quest.targetKills) {
state.quest.status = 'completed'
state.notice = `任务完成:请回 NPC 领取奖励(+${state.quest.rewardPotions} 药草)`
} else {
state.notice = `任务进度:${state.quest.currentKills}/${state.quest.targetKills}`
}
}
const resolveBattle = (victory: boolean) => {
state.battle.turn = 'resolved'
state.battle.victory = victory
if (victory) {
const rewardRange = state.battle.enemyData?.rewardExp ?? [12, 20]
const exp = roll(rewardRange[0], rewardRange[1])
state.notice = `战斗胜利,获得 ${exp} 经验(占位)`
state.battle.message = '胜利。点击“返回探索”继续。'
addBattleLog(`你击败了 ${state.battle.enemyName}`)
updateQuestProgressOnVictory()
} else {
state.notice = '战斗失败(占位):已自动复活到 60 HP'
state.battle.message = '失败。点击“返回探索”继续。'
addBattleLog('你被击倒了。')
state.player.hp = 60
state.player.mp = Math.max(state.player.mp, 10)
updateDerivedBars()
}
clearBattleTimers()
}
const applyEnemyImpact = () => {
const enemy = state.battle.enemyData
if (!enemy || !state.battle.active || state.battle.turn !== 'enemy') return
const useMpStrike = Math.random() < enemy.mpStrikeChance && state.player.mp > 0
if (useMpStrike) {
const mpDamage = roll(6, 13)
const realDamage = Math.min(state.player.mp, mpDamage)
state.player.mp -= realDamage
updateDerivedBars()
emitFx({ kind: 'hit', target: 'player' })
emitFx({ kind: 'popup', target: 'player', value: realDamage, valueType: 'mp', crit: false })
addBattleLog(`${state.battle.enemyName} 施放抽魔,造成 ${realDamage} MP 损失。`)
return
}
const crit = Math.random() < 0.14
const enemyDamage = roll(enemy.minAtk, enemy.maxAtk)
const baseDamage = crit ? Math.floor(enemyDamage * 1.65) : enemyDamage
const finalDamage = state.battle.guarding ? Math.floor(baseDamage * 0.55) : baseDamage
state.player.hp -= finalDamage
state.battle.guarding = false
updateDerivedBars()
emitFx({ kind: 'hit', target: 'player' })
emitFx({ kind: 'popup', target: 'player', value: finalDamage, valueType: 'hp', crit })
addBattleLog(`${state.battle.enemyName} 攻击造成 ${finalDamage} 点伤害${crit ? '(暴击)' : ''}`)
}
const enemyTurn = () => {
if (!state.battle.active || state.battle.turn !== 'enemy') return
const enemy = state.battle.enemyData
const totalAnimMs = enemy?.attackAnimMs ?? 640
const hitDelayMs = Math.min(enemy?.hitDelayMs ?? 360, totalAnimMs - 80)
state.battle.message = `${state.battle.enemyName} 准备攻击...`
emitFx({
kind: 'attack',
actor: 'enemy',
durationMs: totalAnimMs,
hitDelayMs,
})
scheduleBattle(hitDelayMs, () => {
if (!state.battle.active || state.battle.turn !== 'enemy') return
applyEnemyImpact()
if (state.player.hp <= 0) {
resolveBattle(false)
return
}
const recovery = Math.max(120, totalAnimMs - hitDelayMs)
scheduleBattle(recovery, () => {
if (!state.battle.active || state.battle.turn === 'resolved') return
state.battle.turn = 'player'
state.battle.message = '你的回合:选择一个指令。'
})
})
}
const launchPlayerAttack = (isSkill: boolean) => {
const skill = skills[0]
if (isSkill && !skill) return
if (isSkill && state.player.mp < (skill?.mpCost ?? 0)) {
addBattleLog('MP 不足,技能释放失败。')
state.battle.message = 'MP 不足,请重新选择。'
return
}
const totalAnimMs = isSkill ? 820 : 560
const hitDelayMs = isSkill ? 460 : 260
state.battle.turn = 'enemy'
state.battle.message = isSkill ? '你正在施放技能...' : '你正在攻击...'
emitFx({
kind: 'attack',
actor: 'player',
durationMs: totalAnimMs,
hitDelayMs,
})
scheduleBattle(hitDelayMs, () => {
if (!state.battle.active || state.battle.turn !== 'enemy') return
let damage = 0
let crit = false
if (isSkill) {
const castSkill = skills[0]
if (!castSkill) return
state.player.mp -= castSkill.mpCost
damage = roll(castSkill.minDamage, castSkill.maxDamage)
crit = Math.random() < 0.2
if (crit) {
damage = Math.floor(damage * 1.7)
}
addBattleLog(`你使用${castSkill.name},造成 ${damage} 点伤害${crit ? '(暴击)' : ''}`)
} else {
damage = roll(14, 22)
crit = Math.random() < 0.12
if (crit) {
damage = Math.floor(damage * 1.6)
}
addBattleLog(`你使用普通攻击,造成 ${damage} 点伤害${crit ? '(暴击)' : ''}`)
}
state.battle.enemyHp -= damage
if (state.battle.enemyHp < 0) state.battle.enemyHp = 0
updateDerivedBars()
emitFx({ kind: 'hit', target: 'enemy' })
emitFx({ kind: 'popup', target: 'enemy', value: damage, valueType: 'hp', crit })
if (state.battle.enemyHp <= 0) {
resolveBattle(true)
return
}
const recovery = Math.max(120, totalAnimMs - hitDelayMs)
scheduleBattle(recovery + 180, () => {
if (!state.battle.active || state.battle.turn === 'resolved') return
enemyTurn()
})
})
}
export const interactQuestNpc = () => {
if (state.sceneMode !== 'explore') {
return
}
if (state.quest.status === 'not_started') {
state.quest.status = 'in_progress'
state.quest.currentKills = 0
state.notice = `接取任务:${state.quest.title}(击败 ${state.quest.targetKills} 个敌人)`
return
}
if (state.quest.status === 'in_progress') {
state.notice = `任务进行中:${state.quest.currentKills}/${state.quest.targetKills}`
return
}
if (state.quest.status === 'completed') {
state.quest.status = 'turned_in'
state.player.potions += state.quest.rewardPotions
state.player.mp = Math.min(state.player.maxMp, state.player.mp + 8)
updateDerivedBars()
state.notice = `任务已交付:获得 ${state.quest.rewardPotions} 药草MP +8`
return
}
state.notice = 'NPC谢谢你平原暂时安全了。'
}
export const startBattle = (reason = '遭遇野怪') => {
if (state.battle.active || state.sceneMode === 'battleTransition') return
const enemy = pickRandom(enemies)
state.sceneMode = 'battleTransition'
state.notice = `遭遇 ${enemy.name},准备进入战斗...`
clearTimeout(battleEnterTimer)
battleEnterTimer = window.setTimeout(() => {
state.battle.active = true
state.sceneMode = 'battle'
state.battle.turn = 'player'
state.battle.enemyName = enemy.name
state.battle.enemyHp = enemy.hp
state.battle.enemyMaxHp = enemy.hp
state.battle.guarding = false
state.battle.victory = null
state.battle.message = '你的回合:选择一个指令。'
state.battle.log = [`${reason},出现了 ${enemy.name}`]
state.battle.enemyData = enemy
state.battle.fxEvents = []
state.battle.nextFxId = 1
state.encounterMeter = 0
state.notice = `进入战斗:${enemy.name}`
}, 450)
}
export const actInBattle = (action: BattleAction) => {
if (!state.battle.active || state.battle.turn !== 'player') return
if (action === 'attack') {
launchPlayerAttack(false)
return
}
if (action === 'skill') {
launchPlayerAttack(true)
return
}
if (action === 'defend') {
state.battle.guarding = true
addBattleLog('你进入防御姿态,下一次受伤降低。')
state.battle.turn = 'enemy'
state.battle.message = '你进入防御,敌方即将行动...'
scheduleBattle(260, () => {
if (!state.battle.active || state.battle.turn === 'resolved') return
enemyTurn()
})
return
}
if (action === 'item') {
if (state.player.potions <= 0) {
addBattleLog('没有可用药草。')
state.battle.message = '道具不足,请重新选择。'
return
}
state.player.potions -= 1
const heal = 30
state.player.hp = Math.min(state.player.maxHp, state.player.hp + heal)
updateDerivedBars()
emitFx({ kind: 'popup', target: 'player', value: heal, valueType: 'heal', crit: false })
addBattleLog(`你使用药草,恢复 ${heal} HP。`)
state.battle.turn = 'enemy'
state.battle.message = '你使用道具后,敌方即将行动...'
scheduleBattle(300, () => {
if (!state.battle.active || state.battle.turn === 'resolved') return
enemyTurn()
})
}
}
export const closeBattle = () => {
if (!state.battle.active || state.battle.turn !== 'resolved') return
clearBattleTimers()
state.battle.active = false
state.battle.message = ''
state.battle.log = []
state.battle.enemyName = ''
state.battle.enemyData = null
state.battle.fxEvents = []
state.sceneMode = 'explore'
state.notice = '战斗结束,继续探索。'
}
export const useGameState = () => state

5
src/main.ts Normal file
View File

@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')

57
src/style.css Normal file
View File

@ -0,0 +1,57 @@
:root {
color: #d7e0e6;
background-color: #0d1419;
font-family: 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(circle at 20% 20%, #1f2a33, #0d1419 60%);
}
#app {
max-width: 1100px;
margin: 0 auto;
padding: 20px;
}
.app-shell {
display: grid;
gap: 16px;
}
.top-bar {
background: linear-gradient(180deg, #1a2630, #152029);
border: 1px solid #2a3b47;
border-radius: 12px;
padding: 14px 16px;
}
.top-bar h1 {
margin: 0;
font-size: 22px;
letter-spacing: 0.3px;
}
.top-bar p {
margin: 6px 0 0;
color: #97b1c1;
font-size: 14px;
}
.content {
display: grid;
grid-template-columns: 260px 1fr;
gap: 16px;
}
@media (max-width: 900px) {
.content {
grid-template-columns: 1fr;
}
}

675
src/ui/BattlePanel.vue Normal file
View File

@ -0,0 +1,675 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import * as THREE from 'three'
import { actInBattle, closeBattle, useGameState, type BattleFxEvent } from '../game/state'
interface DamagePopup {
id: number
target: 'player' | 'enemy'
value: number
display: number
valueType: 'hp' | 'mp' | 'heal'
crit: boolean
startMs: number
durationMs: number
x: number
y: number
offsetX: number
}
const gameState = useGameState()
const viewport = ref<HTMLDivElement | null>(null)
const popups = ref<DamagePopup[]>([])
let renderer: THREE.WebGLRenderer | null = null
let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null
let resizeObserver: ResizeObserver | null = null
let frameId = 0
let playerUnit: THREE.Mesh | null = null
let enemyUnit: THREE.Mesh | null = null
let playerAura: THREE.Mesh | null = null
let enemyAura: THREE.Mesh | null = null
let rangeRing: THREE.Mesh | null = null
let playerAttackFx: { startMs: number; endMs: number } | null = null
let enemyAttackFx: { startMs: number; endMs: number } | null = null
let playerHitUntilMs = 0
let enemyHitUntilMs = 0
let lastFxId = 0
const raycaster = new THREE.Raycaster()
const pointer = new THREE.Vector2()
const pendingAction = ref<'attack' | 'skill' | null>(null)
const hudHint = ref('')
const playerBase = new THREE.Vector3(-2, 0.55, 2)
const enemyBase = new THREE.Vector3(2, 0.7, -2)
const resetPendingAction = () => {
pendingAction.value = null
hudHint.value = ''
}
const runAction = (action: 'attack' | 'skill' | 'defend' | 'item') => {
if (gameState.battle.turn !== 'player') return
if (action === 'attack' || action === 'skill') {
pendingAction.value = action
hudHint.value = action === 'attack' ? '请选择攻击目标' : '请选择技能目标'
return
}
resetPendingAction()
actInBattle(action)
}
const onPointerDown = (event: PointerEvent) => {
if (!renderer || !camera || !enemyUnit || !pendingAction.value) return
if (gameState.battle.turn !== 'player') return
const rect = renderer.domElement.getBoundingClientRect()
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
raycaster.setFromCamera(pointer, camera)
const hits = raycaster.intersectObject(enemyUnit, false)
if (hits.length === 0) return
actInBattle(pendingAction.value)
resetPendingAction()
}
const processFxEvent = (fx: BattleFxEvent) => {
const now = performance.now()
if (fx.kind === 'attack') {
const total = fx.durationMs ?? 600
if (fx.actor === 'player') {
playerAttackFx = { startMs: now, endMs: now + total }
}
if (fx.actor === 'enemy') {
enemyAttackFx = { startMs: now, endMs: now + total }
}
return
}
if (fx.kind === 'hit') {
if (fx.target === 'player') playerHitUntilMs = now + 240
if (fx.target === 'enemy') enemyHitUntilMs = now + 240
return
}
if (fx.kind === 'popup' && fx.target && fx.value && fx.valueType) {
popups.value.push({
id: fx.id,
target: fx.target,
value: fx.value,
display: fx.value,
valueType: fx.valueType,
crit: Boolean(fx.crit),
startMs: now,
durationMs: 940,
x: 0,
y: 0,
offsetX: (Math.random() - 0.5) * 30,
})
}
}
const updatePopupPositions = (nowMs: number) => {
if (!camera || !renderer || !playerUnit || !enemyUnit) return
const cam = camera
const pUnit = playerUnit
const eUnit = enemyUnit
const width = renderer.domElement.clientWidth
const height = renderer.domElement.clientHeight
const project = (worldPos: THREE.Vector3) => {
const p = worldPos.clone().project(cam)
return {
x: ((p.x + 1) / 2) * width,
y: ((-p.y + 1) / 2) * height,
}
}
popups.value = popups.value
.map((popup) => {
const elapsed = nowMs - popup.startMs
const progress = elapsed / popup.durationMs
const anchor = popup.target === 'player' ? pUnit.position : eUnit.position
const topPos = project(new THREE.Vector3(anchor.x, anchor.y + 1.35, anchor.z))
let display = popup.value
const rollPhase = 420
if (elapsed < rollPhase) {
const min = Math.max(1, Math.floor(popup.value * 0.45))
const max = Math.max(min + 1, Math.floor(popup.value * 1.45))
display = Math.floor(Math.random() * (max - min + 1)) + min
}
return {
...popup,
display,
x: topPos.x + popup.offsetX,
y: topPos.y - progress * 42,
}
})
.filter((popup) => nowMs - popup.startMs < popup.durationMs)
}
const popupText = (popup: DamagePopup) => {
if (popup.valueType === 'heal') return `+${popup.display}`
if (popup.valueType === 'mp') return `-${popup.display} MP`
return `-${popup.display}`
}
const popupClass = (popup: DamagePopup) => {
return {
crit: popup.crit,
hp: popup.valueType === 'hp',
mp: popup.valueType === 'mp',
heal: popup.valueType === 'heal',
}
}
const popupStyle = (popup: DamagePopup) => {
const elapsed = performance.now() - popup.startMs
const progress = Math.min(1, elapsed / popup.durationMs)
const alpha = Math.max(0, 1 - progress)
return {
transform: `translate(${popup.x.toFixed(1)}px, ${popup.y.toFixed(1)}px) scale(${(1 + (popup.crit ? 0.1 : 0)).toFixed(2)})`,
opacity: alpha.toFixed(2),
}
}
const initScene = async () => {
await nextTick()
if (!viewport.value || renderer) return
scene = new THREE.Scene()
scene.background = new THREE.Color('#0f1720')
camera = new THREE.PerspectiveCamera(52, 1, 0.1, 80)
camera.position.set(0, 10.5, 5.8)
camera.lookAt(0, 0, 0)
renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.domElement.style.touchAction = 'none'
renderer.domElement.addEventListener('pointerdown', onPointerDown)
viewport.value.appendChild(renderer.domElement)
const hemi = new THREE.HemisphereLight('#b3e0ff', '#1b2631', 0.7)
scene.add(hemi)
const keyLight = new THREE.DirectionalLight('#fff3ce', 1.15)
keyLight.position.set(4, 8, 2)
scene.add(keyLight)
const arenaBase = new THREE.Mesh(
new THREE.CylinderGeometry(5.8, 6.4, 0.4, 48),
new THREE.MeshStandardMaterial({ color: '#293847' }),
)
arenaBase.position.y = -0.22
scene.add(arenaBase)
const arenaGrid = new THREE.GridHelper(10, 10, '#58758a', '#2b3d4d')
arenaGrid.position.y = 0.01
scene.add(arenaGrid)
const tileMaterialA = new THREE.MeshStandardMaterial({ color: '#1a2a37', metalness: 0.1, roughness: 0.9 })
const tileMaterialB = new THREE.MeshStandardMaterial({ color: '#223543', metalness: 0.1, roughness: 0.9 })
for (let x = -4; x <= 4; x += 2) {
for (let z = -4; z <= 4; z += 2) {
const tile = new THREE.Mesh(
new THREE.PlaneGeometry(2, 2),
(Math.abs(x + z) / 2) % 2 === 0 ? tileMaterialA : tileMaterialB,
)
tile.rotation.x = -Math.PI / 2
tile.position.set(x, 0, z)
scene.add(tile)
}
}
playerUnit = new THREE.Mesh(
new THREE.CapsuleGeometry(0.42, 1.0, 4, 10),
new THREE.MeshStandardMaterial({ color: '#68d8c3' }),
)
playerUnit.position.copy(playerBase)
scene.add(playerUnit)
enemyUnit = new THREE.Mesh(
new THREE.ConeGeometry(0.55, 1.35, 14),
new THREE.MeshStandardMaterial({ color: '#ef8f6b' }),
)
enemyUnit.position.copy(enemyBase)
enemyUnit.rotation.x = Math.PI
scene.add(enemyUnit)
playerAura = new THREE.Mesh(
new THREE.RingGeometry(0.55, 0.76, 24),
new THREE.MeshBasicMaterial({ color: '#86ffdf', transparent: true, opacity: 0.7 }),
)
playerAura.rotation.x = -Math.PI / 2
playerAura.position.set(playerBase.x, 0.02, playerBase.z)
scene.add(playerAura)
enemyAura = new THREE.Mesh(
new THREE.RingGeometry(0.55, 0.76, 24),
new THREE.MeshBasicMaterial({ color: '#ffb193', transparent: true, opacity: 0.7 }),
)
enemyAura.rotation.x = -Math.PI / 2
enemyAura.position.set(enemyBase.x, 0.02, enemyBase.z)
scene.add(enemyAura)
rangeRing = new THREE.Mesh(
new THREE.RingGeometry(1.1, 1.28, 40),
new THREE.MeshBasicMaterial({ color: '#e2f078', transparent: true, opacity: 0.8 }),
)
rangeRing.rotation.x = -Math.PI / 2
rangeRing.position.set(enemyBase.x, 0.03, enemyBase.z)
rangeRing.visible = false
scene.add(rangeRing)
const resize = () => {
if (!viewport.value || !renderer || !camera) return
const width = viewport.value.clientWidth
const height = viewport.value.clientHeight
camera.aspect = width / height
camera.updateProjectionMatrix()
renderer.setSize(width, height)
}
resizeObserver = new ResizeObserver(resize)
resizeObserver.observe(viewport.value)
resize()
lastFxId = 0
const tick = () => {
frameId = requestAnimationFrame(tick)
if (!scene || !camera || !renderer || !playerUnit || !enemyUnit || !playerAura || !enemyAura || !rangeRing) return
if (!gameState.battle.active) return
for (const fx of gameState.battle.fxEvents) {
if (fx.id > lastFxId) {
processFxEvent(fx)
lastFxId = fx.id
}
}
const nowMs = performance.now()
const now = nowMs * 0.001
let playerAtkOffset = 0
if (playerAttackFx && nowMs < playerAttackFx.endMs) {
const p = (nowMs - playerAttackFx.startMs) / (playerAttackFx.endMs - playerAttackFx.startMs)
playerAtkOffset = Math.sin(Math.PI * Math.max(0, Math.min(1, p))) * 1.1
} else {
playerAttackFx = null
}
let enemyAtkOffset = 0
if (enemyAttackFx && nowMs < enemyAttackFx.endMs) {
const p = (nowMs - enemyAttackFx.startMs) / (enemyAttackFx.endMs - enemyAttackFx.startMs)
enemyAtkOffset = Math.sin(Math.PI * Math.max(0, Math.min(1, p))) * 1.05
} else {
enemyAttackFx = null
}
playerUnit.position.set(
playerBase.x + playerAtkOffset,
playerBase.y + Math.sin(now * 3.1) * 0.06,
playerBase.z - playerAtkOffset * 0.2,
)
enemyUnit.position.set(
enemyBase.x - enemyAtkOffset,
enemyBase.y + Math.sin(now * 2.5 + 0.8) * 0.07,
enemyBase.z + enemyAtkOffset * 0.2,
)
const enemyHpRatio = gameState.battle.enemyMaxHp ? gameState.battle.enemyHp / gameState.battle.enemyMaxHp : 1
enemyUnit.scale.setScalar(0.86 + enemyHpRatio * 0.2)
const playerTurn = gameState.battle.turn === 'player'
const enemyTurn = gameState.battle.turn === 'enemy'
;(playerAura.material as THREE.MeshBasicMaterial).opacity = playerTurn ? 0.95 : 0.38
;(enemyAura.material as THREE.MeshBasicMaterial).opacity = enemyTurn ? 0.95 : 0.38
const playerIsHit = nowMs < playerHitUntilMs
const enemyIsHit = nowMs < enemyHitUntilMs
;(playerUnit.material as THREE.MeshStandardMaterial).emissive.setHex(playerIsHit ? 0x6a2323 : 0x0)
;(enemyUnit.material as THREE.MeshStandardMaterial).emissive.setHex(enemyIsHit ? 0x6a2323 : 0x0)
if (pendingAction.value === 'attack') {
rangeRing.visible = true
rangeRing.scale.setScalar(1)
;(rangeRing.material as THREE.MeshBasicMaterial).color.set('#e2f078')
;(rangeRing.material as THREE.MeshBasicMaterial).opacity = 0.82
} else if (pendingAction.value === 'skill') {
rangeRing.visible = true
rangeRing.scale.setScalar(1.35)
;(rangeRing.material as THREE.MeshBasicMaterial).color.set('#f5897c')
;(rangeRing.material as THREE.MeshBasicMaterial).opacity = 0.9
} else {
rangeRing.visible = false
}
rangeRing.position.y = 0.03 + Math.sin(now * 5) * 0.01
updatePopupPositions(nowMs)
camera.lookAt(0, 0, 0)
renderer.render(scene, camera)
}
tick()
}
const disposeScene = () => {
cancelAnimationFrame(frameId)
resizeObserver?.disconnect()
if (renderer) {
renderer.domElement.removeEventListener('pointerdown', onPointerDown)
}
if (viewport.value && renderer && viewport.value.contains(renderer.domElement)) {
viewport.value.removeChild(renderer.domElement)
}
scene?.traverse((object) => {
const mesh = object as THREE.Mesh
if (mesh.geometry) mesh.geometry.dispose()
const material = mesh.material
if (Array.isArray(material)) {
material.forEach((m) => m.dispose())
} else if (material) {
material.dispose()
}
})
renderer?.dispose()
renderer = null
scene = null
camera = null
resizeObserver = null
playerUnit = null
enemyUnit = null
playerAura = null
enemyAura = null
rangeRing = null
playerAttackFx = null
enemyAttackFx = null
popups.value = []
playerHitUntilMs = 0
enemyHitUntilMs = 0
resetPendingAction()
}
watch(
() => gameState.battle.active,
async (active) => {
if (active) {
await initScene()
} else {
disposeScene()
}
},
)
watch(
() => gameState.battle.turn,
(turn) => {
if (turn !== 'player') {
resetPendingAction()
}
},
)
onMounted(async () => {
if (gameState.battle.active) {
await initScene()
}
})
onBeforeUnmount(disposeScene)
</script>
<template>
<div v-if="gameState.battle.active" class="battle-overlay">
<section class="battle-stage">
<div class="battle-viewport-wrap">
<div ref="viewport" class="battle-viewport" />
<div class="popup-layer">
<div
v-for="popup in popups"
:key="popup.id"
class="damage-popup"
:class="popupClass(popup)"
:style="popupStyle(popup)"
>
{{ popupText(popup) }}
</div>
</div>
</div>
<div class="battle-hud">
<header class="battle-head">
<h2>俯视战斗{{ gameState.battle.enemyName }}</h2>
<p>{{ gameState.battle.message }}</p>
<p v-if="hudHint" class="hint">{{ hudHint }}点击敌方单位</p>
</header>
<div class="bars">
<div>
<strong>我方</strong>
<p>HP {{ gameState.hp }} | MP {{ gameState.mp }} | 药草 {{ gameState.player.potions }}</p>
</div>
<div>
<strong>敌方</strong>
<p>HP {{ gameState.battle.enemyHp }} / {{ gameState.battle.enemyMaxHp }}</p>
</div>
</div>
<div class="actions" v-if="gameState.battle.turn !== 'resolved'">
<button :disabled="gameState.battle.turn !== 'player'" @click="runAction('attack')">攻击</button>
<button :disabled="gameState.battle.turn !== 'player'" @click="runAction('skill')">技能</button>
<button :disabled="gameState.battle.turn !== 'player'" @click="runAction('defend')">防御</button>
<button :disabled="gameState.battle.turn !== 'player'" @click="runAction('item')">道具</button>
</div>
<div class="result" v-else>
<p>{{ gameState.battle.victory ? '战斗胜利' : '战斗失败' }}</p>
<button @click="closeBattle">返回探索</button>
</div>
<ul class="log">
<li v-for="(line, index) in gameState.battle.log" :key="`${index}-${line}`">{{ line }}</li>
</ul>
</div>
</section>
</div>
</template>
<style scoped>
.battle-overlay {
position: fixed;
inset: 0;
z-index: 50;
background: radial-gradient(circle at center, rgba(14, 21, 28, 0.86), rgba(5, 9, 12, 0.95));
padding: 14px;
}
.battle-stage {
width: min(1060px, 100%);
height: 100%;
margin: 0 auto;
display: grid;
grid-template-rows: minmax(260px, 58vh) auto;
gap: 10px;
}
.battle-viewport-wrap {
position: relative;
}
.battle-viewport {
border: 1px solid #3b556a;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.35);
background: #101a22;
min-height: 260px;
}
.popup-layer {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
border-radius: 12px;
}
.damage-popup {
position: absolute;
left: 0;
top: 0;
transform: translate(0, 0);
font-weight: 700;
font-size: 23px;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.55);
white-space: nowrap;
will-change: transform, opacity;
}
.damage-popup.hp {
color: #ffb07e;
}
.damage-popup.mp {
color: #6fcbff;
}
.damage-popup.heal {
color: #7ef7a6;
}
.damage-popup.crit {
color: #ffe26a;
font-size: 28px;
}
.battle-hud {
border: 1px solid #304758;
border-radius: 12px;
background: linear-gradient(180deg, #13212c, #0f1a23);
padding: 12px;
}
.battle-head h2 {
margin: 0;
font-size: 20px;
}
.battle-head p {
margin: 4px 0 0;
color: #9ec1d8;
font-size: 13px;
}
.battle-head .hint {
color: #ffd988;
}
.bars {
margin-top: 10px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.bars > div {
border: 1px solid #304758;
border-radius: 8px;
padding: 8px 10px;
background: #162734;
}
.bars p {
margin: 6px 0 0;
font-size: 13px;
}
.actions {
margin-top: 10px;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
button {
border: 1px solid #44657e;
border-radius: 8px;
background: #1e3546;
color: #d7e0e6;
padding: 9px 10px;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.result {
margin-top: 10px;
display: flex;
align-items: center;
justify-content: space-between;
}
.result p {
margin: 0;
}
.log {
margin: 10px 0 0;
border: 1px solid #304758;
border-radius: 8px;
background: #0f1b24;
padding: 10px 12px;
list-style: none;
display: grid;
gap: 5px;
max-height: 140px;
overflow: auto;
font-size: 12px;
}
@media (max-width: 900px) {
.battle-stage {
grid-template-rows: minmax(230px, 48vh) auto;
}
.bars {
grid-template-columns: 1fr;
}
.actions {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

59
src/ui/StatusPanel.vue Normal file
View File

@ -0,0 +1,59 @@
<script setup lang="ts">
import { useGameState } from '../game/state'
const stats = useGameState()
</script>
<template>
<aside class="panel">
<h2>调试状态</h2>
<ul>
<li>Lv. {{ stats.level }}</li>
<li>HP: {{ stats.hp }}</li>
<li>MP: {{ stats.mp }}</li>
<li>药草: {{ stats.player.potions }}</li>
<li>区域: {{ stats.area }}</li>
<li>坐标: {{ stats.positionText }}</li>
<li>移速: {{ stats.moveSpeed.toFixed(1) }}</li>
<li>遇敌计量: {{ stats.encounterMeter.toFixed(0) }}</li>
<li>场景状态: {{ stats.sceneMode }}</li>
<li>战斗状态: {{ stats.battle.active ? '战斗中' : '未战斗' }}</li>
<li>任务: {{ stats.quest.title }}</li>
<li>任务状态: {{ stats.quest.status }}</li>
<li>任务进度: {{ stats.quest.currentKills }} / {{ stats.quest.targetKills }}</li>
</ul>
<p class="notice">{{ stats.notice }}</p>
</aside>
</template>
<style scoped>
.panel {
background: linear-gradient(180deg, #1e2b33, #172127);
border: 1px solid #2c3d49;
border-radius: 12px;
padding: 14px;
}
h2 {
margin: 0 0 8px;
font-size: 16px;
}
ul {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 6px;
font-size: 14px;
}
.notice {
margin: 10px 0 0;
padding: 8px 10px;
border-radius: 8px;
background: #20313d;
color: #9bc2da;
font-size: 12px;
}
</style>

View File

@ -0,0 +1,64 @@
<script setup lang="ts">
import { useGameState } from '../game/state'
const gameState = useGameState()
</script>
<template>
<div v-if="gameState.sceneMode === 'battleTransition'" class="overlay">
<div class="flash" />
<p>Encounter...</p>
</div>
</template>
<style scoped>
.overlay {
position: fixed;
inset: 0;
z-index: 45;
display: grid;
place-items: center;
background: radial-gradient(circle at center, rgba(255, 255, 255, 0.15), rgba(8, 12, 16, 0.95));
animation: fadeIn 0.2s ease-out;
}
.flash {
position: absolute;
inset: 0;
background: repeating-linear-gradient(
-30deg,
rgba(255, 255, 255, 0.06) 0px,
rgba(255, 255, 255, 0.06) 6px,
transparent 6px,
transparent 18px
);
animation: stripeMove 0.7s linear infinite;
}
p {
position: relative;
margin: 0;
font-size: 28px;
letter-spacing: 1px;
color: #e6eef4;
text-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
}
@keyframes stripeMove {
from {
transform: translateX(0);
}
to {
transform: translateX(24px);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>

14
tsconfig.app.json Normal file
View File

@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
tsconfig.node.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

7
vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
})

60
计划清单.md Normal file
View File

@ -0,0 +1,60 @@
# BOF3 风格 RPG 开发计划清单
## 使用说明
- [ ] 每完成一项就勾选,优先保证“可玩闭环”。
- [ ] 所有系统尽量数据驱动(配置文件),减少硬编码。
- [ ] 每周结束至少产出一个可运行版本。
## 里程碑 0项目初始化第 1 周)
- [ ] 初始化前端工程Vue 3 + TypeScript + Vite
- [ ] 集成 Three.js跑通基础 3D 场景。
- [ ] 建立目录结构(`src/core`、`src/game`、`src/ui`、`src/data`)。
- [ ] 建立状态管理(玩家状态、场景状态、战斗状态)。
- [ ] 接入输入系统(键盘/手柄映射预留)。
- [ ] 完成主循环基础update/render 分离)。
- [ ] 输出一个“可移动角色 + 固定相机”的最小场景。
## 里程碑 1探索玩法第 2 周)
- [ ] 搭建一个小地图(城镇或迷宫二选一)。
- [ ] 实现角色移动、碰撞、触发器。
- [ ] 实现 NPC 对话框系统(支持多段文本)。
- [ ] 实现传送点/场景切换。
- [ ] 实现随机遇敌触发(可配置概率)。
- [ ] 场景 UI小地图/状态栏HP、MP
## 里程碑 2回合制战斗 MVP第 3 周)
- [ ] 战斗场景切入/切出流程。
- [ ] 行动顺序系统(速度或时间条机制)。
- [ ] 指令菜单:攻击、技能、道具、防御。
- [ ] 目标选择逻辑(单体/群体)。
- [ ] 伤害与命中公式(先做简化版)。
- [ ] 胜负判定与战斗结算。
- [ ] 至少 3 种敌人、3 个技能可用。
## 里程碑 3成长与存档第 4 周)
- [ ] 经验与升级逻辑。
- [ ] 角色属性成长(力量、防御、速度等)。
- [ ] 装备系统(武器/防具基础版)。
- [ ] 物品背包与消耗品效果。
- [ ] 本地存档IndexedDB 或 localStorage
- [ ] 读档恢复场景与角色状态。
## 里程碑 4内容整合与打磨第 5-6 周)
- [ ] 完成 10~20 分钟可玩流程。
- [ ] 至少 1 个短任务链(接任务->战斗->回报)。
- [ ] 补齐音效与基础 BGM 切换。
- [ ] 增加战斗反馈(受击闪烁、技能特效、数字飘字)。
- [ ] 关键性能优化(减少 draw call、纹理压缩、对象池
- [ ] 基础设置菜单(音量、按键、画质档位)。
## 技术债与风险清单(持续跟踪)
- [ ] 资源版权检查(严禁直接使用原作素材)。
- [ ] 战斗与探索状态切换稳定性。
- [ ] 数据配置版本兼容(旧存档升级策略)。
- [ ] 移动端/低端设备帧率监控。
## 验收标准Demo
- [ ] 玩家能完成“探索 -> 遇敌 -> 战斗 -> 结算 -> 存档/读档”全链路。
- [ ] 连续游玩 15 分钟无阻断性 Bug。
- [ ] 平均帧率达到目标(例如桌面端 60 FPS、低端设备 30 FPS
- [ ] 主要系统可通过配置扩展内容(怪物、技能、道具)。