Compare commits
No commits in common. "bab629a29ea94a71ebf8dfb7bfd076b1e05b45c9" and "d56da176c84dc6adebba3e36e24236b0167c19e1" have entirely different histories.
bab629a29e
...
d56da176c8
|
|
@ -190,27 +190,3 @@ replay_pid*
|
||||||
# Go workspace file
|
# Go workspace file
|
||||||
go.work
|
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?
|
|
||||||
|
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
# 任务执行摘要
|
|
||||||
|
|
||||||
## 会话 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,引入 sceneMode(explore/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
13
index.html
|
|
@ -1,13 +0,0 @@
|
||||||
<!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>
|
|
||||||
File diff suppressed because it is too large
Load Diff
24
package.json
24
package.json
|
|
@ -1,24 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB |
|
|
@ -1,24 +0,0 @@
|
||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 4.9 KiB |
23
src/App.vue
23
src/App.vue
|
|
@ -1,23 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
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,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
export const gameConfig = {
|
|
||||||
title: 'Breath of Fire-like RPG Demo',
|
|
||||||
tagline: '里程碑 7: 攻击/受击动画 + 时序反馈 + 飘字系统',
|
|
||||||
}
|
|
||||||
|
|
@ -1,272 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,451 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import { createApp } from 'vue'
|
|
||||||
import App from './App.vue'
|
|
||||||
import './style.css'
|
|
||||||
|
|
||||||
createApp(App).mount('#app')
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
: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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,675 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
{
|
|
||||||
"files": [],
|
|
||||||
"references": [
|
|
||||||
{ "path": "./tsconfig.app.json" },
|
|
||||||
{ "path": "./tsconfig.node.json" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { defineConfig } from 'vite'
|
|
||||||
import vue from '@vitejs/plugin-vue'
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [vue()],
|
|
||||||
})
|
|
||||||
60
计划清单.md
60
计划清单.md
|
|
@ -1,60 +0,0 @@
|
||||||
# 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)。
|
|
||||||
- [ ] 主要系统可通过配置扩展内容(怪物、技能、道具)。
|
|
||||||
Loading…
Reference in New Issue