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.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