feat(editor): 完成英雄配置编辑器全功能开发
本提交新增完整的英雄配置可视化编辑器: 1. 初始化前后端项目结构,配置monorepo工作区 2. 实现后端API服务,支持读取游戏配置、导出hexport.ts文件 3. 开发前端Vue应用,包含完整的英雄增删改、属性配置、技能编辑功能 4. 实现多类型编辑器组件:基础属性、普攻/大招编辑、触发技能、光环、复活、属性加成等 5. 清理冗余的配置元数据文件
This commit is contained in:
4
put/.gitignore
vendored
Normal file
4
put/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
*.local
|
||||
.DS_Store
|
||||
3380
put/package-lock.json
generated
Normal file
3380
put/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
put/package.json
Normal file
18
put/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "hero-config-tool",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "英雄配置编辑器 - pixelheros",
|
||||
"scripts": {
|
||||
"dev": "concurrently -n server,web -c blue,green \"npm run dev -w server\" \"npm run dev -w web\"",
|
||||
"build": "npm run build -w web",
|
||||
"export:check": "node server/scripts/check-export.js"
|
||||
},
|
||||
"workspaces": [
|
||||
"server",
|
||||
"web"
|
||||
],
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.0"
|
||||
}
|
||||
}
|
||||
229
put/server/index.js
Normal file
229
put/server/index.js
Normal file
@@ -0,0 +1,229 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import esbuild from 'esbuild'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import url from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(url.fileURLToPath(import.meta.url))
|
||||
const PROJECT_ROOT = path.resolve(__dirname, '..', '..')
|
||||
const CONFIG_DIR = path.join(PROJECT_ROOT, 'assets/script/game/common/config')
|
||||
const EXPORT_PATH = path.join(CONFIG_DIR, 'hexport.ts')
|
||||
|
||||
const app = express()
|
||||
app.use(cors())
|
||||
app.use(express.json({ limit: '5mb' }))
|
||||
|
||||
/** 编译配置 TS 文件为可执行 CJS,返回模块 exports */
|
||||
async function loadConfigModule(file) {
|
||||
const result = await esbuild.build({
|
||||
entryPoints: [path.join(CONFIG_DIR, file)],
|
||||
bundle: true,
|
||||
write: false,
|
||||
format: 'cjs',
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
external: ['cc'],
|
||||
logLevel: 'silent',
|
||||
})
|
||||
const code = result.outputFiles[0].text
|
||||
const module = { exports: {} }
|
||||
// 屏蔽对 cc 的引用(v3 等)
|
||||
const fn = new Function('module', 'exports', 'require', code)
|
||||
fn(module, module.exports, () => ({ v3: () => ({}) }))
|
||||
return module.exports
|
||||
}
|
||||
|
||||
let cache = null
|
||||
|
||||
async function buildCache() {
|
||||
const [skill, buff, hero] = await Promise.all([
|
||||
loadConfigModule('SkillSet.ts'),
|
||||
loadConfigModule('BuffSet.ts'),
|
||||
loadConfigModule('heroSet.ts'),
|
||||
])
|
||||
cache = {
|
||||
SkillSet: skill.SkillSet,
|
||||
FieldSkillSet: skill.FieldSkillSet,
|
||||
BuffList: buff.BuffList,
|
||||
HERO_ROLE_BASE: hero.HERO_ROLE_BASE,
|
||||
HERO_GROW_COEF: hero.HERO_GROW_COEF,
|
||||
HERO_LV12_SPECIAL_ATTR: hero.HERO_LV12_SPECIAL_ATTR,
|
||||
AtkSpeedSet: hero.AtkSpeedSet,
|
||||
HeroDisVal: hero.HeroDisVal,
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
app.get('/api/meta', async (_req, res) => {
|
||||
try {
|
||||
if (!cache) await buildCache()
|
||||
res.json(cache)
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e?.message || e) })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/refresh', async (_req, res) => {
|
||||
try {
|
||||
await buildCache()
|
||||
res.json({ ok: true })
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e?.message || e) })
|
||||
}
|
||||
})
|
||||
|
||||
/** 生成 hexport.ts 内容 */
|
||||
function renderHeroTs(heroes) {
|
||||
const nl = '\n'
|
||||
let out = ''
|
||||
out += `/**${nl}`
|
||||
out += ` * 英雄配置导出文件 - 由英雄配置编辑器生成${nl}`
|
||||
out += ` * 生成时间: ${new Date().toISOString()}${nl}`
|
||||
out += ` * 使用方式: import { HexportHeroInfo } from "./hexport" 后合并进 HeroInfo${nl}`
|
||||
out += ` */${nl}`
|
||||
out += `import { v3 } from "cc"${nl}`
|
||||
out += `import { FacSet } from "./GameSet"${nl}`
|
||||
out += `import { SkillLv, HType, HGrowType, HRole, HERO_ROLE_BASE, AtkSpeedSet, AtkSpeedLv, heroInfo } from "./heroSet"${nl}`
|
||||
out += nl
|
||||
out += `export const HexportHeroInfo: Record<number, heroInfo> = {${nl}`
|
||||
for (const h of heroes) {
|
||||
out += renderSingleHero(h)
|
||||
}
|
||||
out += `}${nl}`
|
||||
return out
|
||||
}
|
||||
|
||||
function renderSingleHero(h) {
|
||||
const lines = []
|
||||
const indent = ' '
|
||||
const push = (s) => lines.push(indent + s)
|
||||
push(`// ${h.uuid} ${h.name} — ${h.comment || ''}`)
|
||||
push(`${h.uuid}: {`)
|
||||
const roleExpr = `HERO_ROLE_BASE[HRole.${h.roleKey}]`
|
||||
const stat = (k) => {
|
||||
const base = h.statBase?.[k] ?? 0
|
||||
if (base === 0) return `${roleExpr}.${k}`
|
||||
return `${roleExpr}.${k} ${base > 0 ? '+' : '-'} ${Math.abs(base)}`
|
||||
}
|
||||
push(
|
||||
` uuid: ${h.uuid}, name: ${JSON.stringify(h.name)}, path: ${JSON.stringify(h.path)}, fac: FacSet.HERO, rarity: ${h.rarity}, lv: 1, type: HType.${h.typeKey}, grow_type: HGrowType.${h.growKey}, role: HRole.${h.roleKey}, hp: ${stat('hp')}, ap: ${stat('ap')}, def: ${stat('def')},`
|
||||
)
|
||||
if (h.dis != null) push(` dis: ${h.dis},`)
|
||||
if (h.speed != null) push(` speed: ${h.speed},`)
|
||||
|
||||
// skills
|
||||
push(` skills: {`)
|
||||
for (const sk of h.skills) {
|
||||
const parts = [`uuid: ${sk.uuid}`]
|
||||
if (sk.name) parts.push(`name: ${JSON.stringify(sk.name)}`)
|
||||
parts.push(`lv: 1`)
|
||||
parts.push(`cd: ${sk.cdExpr || sk.cd}`)
|
||||
parts.push(`ccd: 0`)
|
||||
if (sk.overrides && Object.keys(sk.overrides).length) parts.push(`overrides: ${JSON.stringify(sk.overrides)}`)
|
||||
if (sk.desc) parts.push(`desc: ${JSON.stringify(sk.desc)}`)
|
||||
if (sk.lv_entries?.length) {
|
||||
push(` ${sk.uuid}: { ${parts.join(', ')},`)
|
||||
push(` lv_entries: [`)
|
||||
for (const e of sk.lv_entries) {
|
||||
const eparts = [`lv: ${e.lv}`]
|
||||
if (e.s_lv) eparts.push(`s_lv: SkillLv.Lv${e.s_lv}`)
|
||||
if (e.overrides && Object.keys(e.overrides).length) eparts.push(`overrides: ${JSON.stringify(e.overrides)}`)
|
||||
if (e.desc) eparts.push(`desc: ${JSON.stringify(e.desc)}`)
|
||||
push(` { ${eparts.join(', ')} },`)
|
||||
}
|
||||
push(` ],`)
|
||||
push(` },`)
|
||||
} else {
|
||||
push(` ${sk.uuid}: { ${parts.join(', ')} },`)
|
||||
}
|
||||
}
|
||||
push(` },`)
|
||||
|
||||
// triggers
|
||||
const triggerKeys = ['atking', 'atked', 'fstart', 'dead']
|
||||
for (const tk of triggerKeys) {
|
||||
const grp = h[tk]
|
||||
if (!grp || !Object.keys(grp).length) continue
|
||||
push(` ${tk}: {`)
|
||||
for (const [sid, entries] of Object.entries(grp)) {
|
||||
push(` ${sid}: [`)
|
||||
for (const e of entries) {
|
||||
const eparts = [`lv: ${e.lv}`]
|
||||
if (e.s_lv) eparts.push(`s_lv: SkillLv.Lv${e.s_lv}`)
|
||||
if (e.name) eparts.push(`name: ${JSON.stringify(e.name)}`)
|
||||
eparts.push(`t_num: ${e.t_num}`)
|
||||
if (e.overrides && Object.keys(e.overrides).length) eparts.push(`overrides: ${JSON.stringify(e.overrides)}`)
|
||||
if (e.desc) eparts.push(`desc: ${JSON.stringify(e.desc)}`)
|
||||
push(` { ${eparts.join(', ')} },`)
|
||||
}
|
||||
push(` ],`)
|
||||
}
|
||||
push(` },`)
|
||||
}
|
||||
|
||||
// field
|
||||
if (h.field?.length) {
|
||||
push(` field: [`)
|
||||
for (const f of h.field) {
|
||||
const uuids = f.uuids.map((u) => {
|
||||
const uparts = [`uuid: ${u.uuid}`, `value: ${u.value}`]
|
||||
if (u.name) uparts.push(`name: ${JSON.stringify(u.name)}`)
|
||||
return `{ ${uparts.join(', ')} }`
|
||||
})
|
||||
const desc = f.desc ? `, desc: ${JSON.stringify(f.desc)}` : ''
|
||||
push(` { lv: ${f.lv}, uuids: [${uuids.join(', ')}]${desc} },`)
|
||||
}
|
||||
push(` ],`)
|
||||
}
|
||||
|
||||
// revive
|
||||
if (h.revive?.length) {
|
||||
push(` revive: [`)
|
||||
for (const r of h.revive) {
|
||||
const rparts = [`lv: ${r.lv}`, `s_uuid: ${r.s_uuid}`, `r_num: ${r.r_num}`, `upr: ${r.upr}`]
|
||||
if (r.name) rparts.push(`name: ${JSON.stringify(r.name)}`)
|
||||
if (r.desc) rparts.push(`desc: ${JSON.stringify(r.desc)}`)
|
||||
push(` { ${rparts.join(', ')} },`)
|
||||
}
|
||||
push(` ],`)
|
||||
}
|
||||
|
||||
// bonuses
|
||||
for (const bk of ['ap_bonus', 'hp_bonus', 'def_bonus']) {
|
||||
if (!h[bk]?.length) continue
|
||||
push(` ${bk}: [`)
|
||||
for (const b of h[bk]) push(` { lv: ${b.lv}, value: ${b.value} },`)
|
||||
push(` ],`)
|
||||
}
|
||||
|
||||
push(` info: ${JSON.stringify(h.info || '')},`)
|
||||
push(`},`)
|
||||
return lines.join('\n') + '\n'
|
||||
}
|
||||
|
||||
app.post('/api/export', (req, res) => {
|
||||
try {
|
||||
const heroes = req.body?.heroes
|
||||
if (!Array.isArray(heroes) || !heroes.length) {
|
||||
return res.status(400).json({ error: 'heroes 不能为空' })
|
||||
}
|
||||
const content = renderHeroTs(heroes)
|
||||
fs.writeFileSync(EXPORT_PATH, content, 'utf8')
|
||||
res.json({ ok: true, path: EXPORT_PATH, bytes: Buffer.byteLength(content) })
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e?.message || e) })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/export', (_req, res) => {
|
||||
if (!fs.existsSync(EXPORT_PATH)) return res.status(404).json({ error: '尚未生成' })
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
||||
res.send(fs.readFileSync(EXPORT_PATH, 'utf8'))
|
||||
})
|
||||
|
||||
const PORT = 3001
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[hero-config-tool] server listening http://localhost:${PORT}`)
|
||||
console.log(`[hero-config-tool] export path: ${EXPORT_PATH}`)
|
||||
})
|
||||
14
put/server/package.json
Normal file
14
put/server/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node --watch index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"esbuild": "^0.24.0",
|
||||
"express": "^4.21.2"
|
||||
}
|
||||
}
|
||||
21
put/server/scripts/verify-meta.js
Normal file
21
put/server/scripts/verify-meta.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// 验证 server /api/meta 是否正常工作
|
||||
import http from 'node:http'
|
||||
|
||||
http.get('http://localhost:3001/api/meta', (res) => {
|
||||
let data = ''
|
||||
res.on('data', (c) => (data += c))
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const m = JSON.parse(data)
|
||||
console.log('SkillSet:', Object.keys(m.SkillSet || {}).length)
|
||||
console.log('FieldSkillSet:', Object.keys(m.FieldSkillSet || {}).length)
|
||||
console.log('BuffList:', Object.keys(m.BuffList || {}).length)
|
||||
console.log('HERO_ROLE_BASE:', Object.keys(m.HERO_ROLE_BASE || {}).length)
|
||||
console.log('AtkSpeedSet:', Object.keys(m.AtkSpeedSet || {}).length)
|
||||
const s6010 = m.SkillSet?.[6010]
|
||||
console.log('Skill 6010:', s6010 ? `${s6010.name} ap=${s6010.ap}` : 'missing')
|
||||
} catch (e) {
|
||||
console.error('parse fail:', e.message, 'raw:', data.slice(0, 200))
|
||||
}
|
||||
})
|
||||
}).on('error', (e) => console.error('http err:', e.message))
|
||||
12
put/web/index.html
Normal file
12
put/web/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>英雄配置编辑器</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
20
put/web/package.json
Normal file
20
put/web/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"element-plus": "^2.9.1",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
271
put/web/src/App.vue
Normal file
271
put/web/src/App.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div class="hero-app">
|
||||
<header class="hero-header">
|
||||
<h1>英雄配置编辑器</h1>
|
||||
<div class="actions">
|
||||
<el-button size="small" @click="refreshMeta" :loading="loadingMeta">刷新配置数据</el-button>
|
||||
<el-button size="small" type="warning" @click="doExport" :disabled="!heroes.length">导出 hexport.ts</el-button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="hero-main">
|
||||
<aside class="hero-sidebar">
|
||||
<div class="side-head">
|
||||
<span>英雄列表 ({{ heroes.length }})</span>
|
||||
<el-button size="small" type="primary" text @click="addHero">+ 新增</el-button>
|
||||
</div>
|
||||
<div class="hero-list">
|
||||
<div
|
||||
v-for="(h, i) in heroes"
|
||||
:key="i"
|
||||
class="hero-item"
|
||||
:class="{ active: i === activeIdx }"
|
||||
@click="activeIdx = i"
|
||||
>
|
||||
<div>
|
||||
<div>{{ h.uuid }} {{ h.name || '(未命名)' }}</div>
|
||||
<div class="meta">{{ roleLabel(h.roleKey) }} · {{ typeLabel(h.typeKey) }} · ★{{ h.rarity }}</div>
|
||||
</div>
|
||||
<el-button type="danger" size="small" text @click.stop="removeHero(i)">删</el-button>
|
||||
</div>
|
||||
<div v-if="!heroes.length" class="empty">点击右上角"新增"创建英雄</div>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="hero-editor" v-if="current">
|
||||
<!-- 基础属性 -->
|
||||
<div class="panel">
|
||||
<h3>基础属性</h3>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="uuid">
|
||||
<el-input-number v-model="current.uuid" :min="5001" :controls="false" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="current.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="资源路径 path">
|
||||
<el-input v-model="current.path" placeholder="hm1 / hk2 ..." />
|
||||
</el-form-item>
|
||||
<el-form-item label="品质 rarity">
|
||||
<el-select v-model="current.rarity">
|
||||
<el-option v-for="n in 5" :key="n" :label="`${n} 星`" :value="n" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="职业 role">
|
||||
<el-select v-model="current.roleKey">
|
||||
<el-option v-for="r in HERO_ROLES" :key="r.key" :label="r.label" :value="r.key" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="成长 grow_type">
|
||||
<el-select v-model="current.growKey">
|
||||
<el-option v-for="g in GROW_TYPES" :key="g.key" :label="g.label" :value="g.key" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="形态 type">
|
||||
<el-select v-model="current.typeKey">
|
||||
<el-option v-for="t in H_TYPES" :key="t.key" :label="t.label" :value="t.key" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="攻击距离 dis(可选)">
|
||||
<el-input-number v-model="current.dis" :min="0" :controls="false" style="width: 100%" placeholder="默认按形态" />
|
||||
</el-form-item>
|
||||
<el-form-item label="hp 偏移">
|
||||
<el-input-number v-model="current.statBase.hp" :controls="false" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="ap 偏移">
|
||||
<el-input-number v-model="current.statBase.ap" :controls="false" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="def 偏移">
|
||||
<el-input-number v-model="current.statBase.def" :controls="false" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="注释">
|
||||
<el-input v-model="current.comment" placeholder="导出文件中显示在条目上方" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="muted">基础值 = HERO_ROLE_BASE[{{ current.roleKey }}] → hp {{ roleBase?.hp }} / ap {{ roleBase?.ap }} / def {{ roleBase?.def }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 普攻 -->
|
||||
<div class="panel">
|
||||
<h3>普攻 skills[0]</h3>
|
||||
<SkillEditor v-if="basicSkill" :skill="basicSkill" :options="basicSkillOptions" />
|
||||
<el-button v-else size="small" type="primary" @click="addBasicSkill">+ 添加普攻</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 大招 -->
|
||||
<div class="panel">
|
||||
<h3>大招 skills[1](与 revive 互斥)</h3>
|
||||
<template v-if="current.revive.length === 0">
|
||||
<SkillEditor v-if="ultSkill" :skill="ultSkill" :options="ultSkillOptions" :is-ult="true" />
|
||||
<el-button v-else size="small" type="primary" @click="addUltSkill">+ 添加大招</el-button>
|
||||
</template>
|
||||
<el-alert v-else type="info" :closable="false">已配置 revive,大招不可用</el-alert>
|
||||
</div>
|
||||
|
||||
<!-- 触发技能 -->
|
||||
<div class="panel">
|
||||
<h3>触发技能(lv6 激活)</h3>
|
||||
<el-tabs>
|
||||
<el-tab-pane label="atking 攻击后"><TriggerGroupEditor :model="current.atking" :skill-options="allSkills" :skill-map="skillMap" /></el-tab-pane>
|
||||
<el-tab-pane label="atked 受击后"><TriggerGroupEditor :model="current.atked" :skill-options="allSkills" :skill-map="skillMap" /></el-tab-pane>
|
||||
<el-tab-pane label="fstart 战斗开始"><TriggerGroupEditor :model="current.fstart" :skill-options="allSkills" :skill-map="skillMap" /></el-tab-pane>
|
||||
<el-tab-pane label="dead 死亡后"><TriggerGroupEditor :model="current.dead" :skill-options="allSkills" :skill-map="skillMap" /></el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<!-- 光环 -->
|
||||
<div class="panel">
|
||||
<h3>驻场光环 field</h3>
|
||||
<FieldEditor :model="current.field" :field-options="fieldOptions" />
|
||||
</div>
|
||||
|
||||
<!-- 复活 -->
|
||||
<div class="panel">
|
||||
<h3>复活 revive(与大招互斥)</h3>
|
||||
<ReviveEditor :model="current.revive" :revive-options="reviveOptions" />
|
||||
</div>
|
||||
|
||||
<!-- 额外加成 -->
|
||||
<div class="panel">
|
||||
<h3>额外属性加成</h3>
|
||||
<el-tabs>
|
||||
<el-tab-pane label="ap_bonus"><BonusEditor :model="current.ap_bonus" /></el-tab-pane>
|
||||
<el-tab-pane label="hp_bonus"><BonusEditor :model="current.hp_bonus" /></el-tab-pane>
|
||||
<el-tab-pane label="def_bonus"><BonusEditor :model="current.def_bonus" /></el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<!-- 描述 -->
|
||||
<div class="panel">
|
||||
<h3>英雄描述 info</h3>
|
||||
<el-input v-model="current.info" type="textarea" :rows="3" />
|
||||
</div>
|
||||
</section>
|
||||
<section v-else class="hero-editor"><div class="empty">请先选择或新增英雄</div></section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, h } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { api } from './api'
|
||||
import {
|
||||
HERO_ROLES, GROW_TYPES, H_TYPES,
|
||||
ROLE_ATK_SKILL, ROLE_ULT_SKILL, SUPPORT_ULT,
|
||||
defaultHero, defaultSkill,
|
||||
} from './constants'
|
||||
import SkillEditor from './components/SkillEditor.vue'
|
||||
import TriggerGroupEditor from './components/TriggerGroupEditor.vue'
|
||||
import FieldEditor from './components/FieldEditor.vue'
|
||||
import ReviveEditor from './components/ReviveEditor.vue'
|
||||
import BonusEditor from './components/BonusEditor.vue'
|
||||
|
||||
const heroes = ref([])
|
||||
const activeIdx = ref(-1)
|
||||
const meta = ref(null)
|
||||
const loadingMeta = ref(false)
|
||||
|
||||
const current = computed(() => heroes.value[activeIdx.value])
|
||||
|
||||
const skillMap = computed(() => meta.value?.SkillSet || {})
|
||||
const allSkills = computed(() => Object.values(skillMap.value).sort((a, b) => a.uuid - b.uuid))
|
||||
const fieldOptions = computed(() => Object.values(meta.value?.FieldSkillSet || {}).sort((a, b) => a.uuid - b.uuid))
|
||||
const reviveOptions = computed(() => allSkills.value.filter((s) => s.uuid >= 6500 && s.uuid < 6600))
|
||||
|
||||
const roleBase = computed(() => {
|
||||
if (!current.value || !meta.value?.HERO_ROLE_BASE) return null
|
||||
const roleEnum = HERO_ROLES.find((r) => r.key === current.value.roleKey)?.value
|
||||
return meta.value.HERO_ROLE_BASE[roleEnum]
|
||||
})
|
||||
|
||||
const basicSkill = computed(() => current.value?.skills?.[0])
|
||||
const ultSkill = computed(() => current.value?.skills?.[1])
|
||||
|
||||
const basicSkillOptions = computed(() => {
|
||||
if (!current.value) return []
|
||||
const allowed = ROLE_ATK_SKILL[current.value.roleKey] || []
|
||||
return allSkills.value.filter((s) => allowed.includes(s.uuid))
|
||||
})
|
||||
|
||||
const ultSkillOptions = computed(() => {
|
||||
if (!current.value) return []
|
||||
const allowed = [...(ROLE_ULT_SKILL[current.value.roleKey] || []), ...SUPPORT_ULT]
|
||||
return allSkills.value.filter((s) => allowed.includes(s.uuid))
|
||||
})
|
||||
|
||||
function roleLabel(key) { return HERO_ROLES.find((r) => r.key === key)?.label || key }
|
||||
function typeLabel(key) { return H_TYPES.find((t) => t.key === key)?.label || key }
|
||||
|
||||
async function loadMeta() {
|
||||
loadingMeta.value = true
|
||||
try {
|
||||
meta.value = await api.meta()
|
||||
} catch (e) {
|
||||
ElMessage.error('加载配置数据失败: ' + (e?.response?.data?.error || e.message))
|
||||
} finally {
|
||||
loadingMeta.value = false
|
||||
}
|
||||
}
|
||||
async function refreshMeta() {
|
||||
try {
|
||||
await api.refresh()
|
||||
await loadMeta()
|
||||
ElMessage.success('已刷新')
|
||||
} catch (e) {
|
||||
ElMessage.error('刷新失败')
|
||||
}
|
||||
}
|
||||
|
||||
function addHero() {
|
||||
const nh = defaultHero()
|
||||
const used = new Set(heroes.value.map((h) => h.uuid))
|
||||
let uuid = 5001
|
||||
while (used.has(uuid)) uuid++
|
||||
nh.uuid = uuid
|
||||
heroes.value.push(nh)
|
||||
activeIdx.value = heroes.value.length - 1
|
||||
}
|
||||
function removeHero(i) {
|
||||
heroes.value.splice(i, 1)
|
||||
if (activeIdx.value >= heroes.value.length) activeIdx.value = heroes.value.length - 1
|
||||
}
|
||||
|
||||
function addBasicSkill() {
|
||||
if (!current.value) return
|
||||
const first = basicSkillOptions.value[0]
|
||||
if (!first) return ElMessage.warning('当前职业无可用普攻底座')
|
||||
current.value.skills[0] = defaultSkill(first.uuid)
|
||||
}
|
||||
function addUltSkill() {
|
||||
if (!current.value) return
|
||||
const first = ultSkillOptions.value[0]
|
||||
if (!first) return ElMessage.warning('当前职业无可用大招底座')
|
||||
const sk = defaultSkill(first.uuid)
|
||||
// 大招默认 summon_count=1,并提供 lv9/lv16 档位
|
||||
sk.cd = 8
|
||||
sk.cdExpr = null
|
||||
sk.overrides = { summon_count: 1 }
|
||||
sk.lv_entries = [
|
||||
{ lv: 9, s_lv: 2, overrides: { summon_count: 1 }, desc: '' },
|
||||
{ lv: 16, s_lv: 3, overrides: { summon_count: 2 }, desc: '' },
|
||||
]
|
||||
current.value.skills[1] = sk
|
||||
}
|
||||
|
||||
async function doExport() {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`将写入 ${heroes.value.length} 个英雄到 assets/script/game/common/config/hexport.ts`,
|
||||
'导出确认',
|
||||
{ confirmButtonText: '写入', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch { return }
|
||||
try {
|
||||
const r = await api.exportHeroes(heroes.value)
|
||||
ElMessage.success(`已写入 ${r.bytes} 字节 → ${r.path}`)
|
||||
} catch (e) {
|
||||
ElMessage.error('导出失败: ' + (e?.response?.data?.error || e.message))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadMeta)
|
||||
</script>
|
||||
8
put/web/src/api.js
Normal file
8
put/web/src/api.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export const api = {
|
||||
meta: () => axios.get('/api/meta').then((r) => r.data),
|
||||
refresh: () => axios.post('/api/refresh').then((r) => r.data),
|
||||
exportHeroes: (heroes) => axios.post('/api/export', { heroes }).then((r) => r.data),
|
||||
fetchExport: () => axios.get('/api/export', { responseType: 'text' }).then((r) => r.data),
|
||||
}
|
||||
14
put/web/src/components/BonusEditor.vue
Normal file
14
put/web/src/components/BonusEditor.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-for="(b, idx) in model" :key="idx" style="display: flex; gap: 8px; margin-bottom: 6px">
|
||||
<el-input-number v-model="b.lv" :min="1" :max="20" :controls="false" style="width: 100px" placeholder="lv" />
|
||||
<el-input-number v-model="b.value" :controls="false" style="width: 140px" placeholder="value" />
|
||||
<el-button type="danger" size="small" text @click="model.splice(idx, 1)">删除</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="model.push({ lv: 12, value: 0 })">+ 添加</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({ model: { type: Array, required: true } })
|
||||
</script>
|
||||
43
put/web/src/components/FieldEditor.vue
Normal file
43
put/web/src/components/FieldEditor.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-for="(f, idx) in model" :key="idx" class="skill-block">
|
||||
<div class="head">
|
||||
<span>光环档位 lv {{ f.lv }}</span>
|
||||
<el-button type="danger" size="small" text @click="model.splice(idx, 1)">删除档位</el-button>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="等级 lv">
|
||||
<el-input-number v-model="f.lv" :min="1" :max="20" :controls="false" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" class="grid-span">
|
||||
<el-input v-model="f.desc" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="光环词条">
|
||||
<div style="width: 100%">
|
||||
<div v-for="(u, i) in f.uuids" :key="i" style="display: flex; gap: 8px; margin-bottom: 6px">
|
||||
<el-select v-model="u.uuid" placeholder="光环" filterable style="width: 220px">
|
||||
<el-option v-for="fs in fieldOptions" :key="fs.uuid" :label="`${fs.uuid} ${fs.name}`" :value="fs.uuid" />
|
||||
</el-select>
|
||||
<el-input-number v-model="u.value" :step="0.01" :controls="false" style="width: 120px" placeholder="value" />
|
||||
<el-input v-model="u.name" placeholder="英雄语境名(可选)" style="width: 200px" />
|
||||
<el-button type="danger" size="small" text @click="f.uuids.splice(i, 1)">删除</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="f.uuids.push({ uuid: null, value: 0, name: '' })">+ 添加词条</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-button size="small" type="primary" @click="model.push({ lv: 6, uuids: [], desc: '' })">+ 添加光环档位</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
model: { type: Array, required: true },
|
||||
fieldOptions: { type: Array, default: () => [] },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid-span { grid-column: 1 / -1; }
|
||||
</style>
|
||||
56
put/web/src/components/OverrideEditor.vue
Normal file
56
put/web/src/components/OverrideEditor.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="override-editor">
|
||||
<div v-for="(item, idx) in rows" :key="idx" class="override-row">
|
||||
<el-select v-model="item.key" placeholder="字段" size="small" style="width: 200px" filterable>
|
||||
<el-option v-for="k in availableKeys(idx)" :key="k.key" :label="k.label" :value="k.key" />
|
||||
</el-select>
|
||||
<el-input-number v-model="item.value" size="small" :controls="false" style="width: 120px" />
|
||||
<el-button type="danger" size="small" text @click="remove(idx)">删除</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="add">+ 添加覆盖</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { OVERRIDE_KEYS } from '../constants'
|
||||
|
||||
const props = defineProps({ modelValue: { type: Object, default: () => ({}) } })
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
// 本地行数组,允许 key 为空
|
||||
const rows = ref(Object.entries(props.modelValue || {}).map(([key, value]) => ({ key, value })))
|
||||
|
||||
// 外部 modelValue 变化时同步(如切换英雄)
|
||||
watch(() => props.modelValue, (v) => {
|
||||
const next = Object.entries(v || {}).map(([key, value]) => ({ key, value }))
|
||||
// 简单比较,避免输入时循环
|
||||
if (JSON.stringify(next) !== JSON.stringify(rows.value.filter(r => r.key))) {
|
||||
rows.value = next
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// 每次行变化都尝试同步(过滤空 key)
|
||||
watch(rows, (arr) => {
|
||||
const obj = {}
|
||||
for (const it of arr) if (it.key) obj[it.key] = it.value
|
||||
emit('update:modelValue', obj)
|
||||
}, { deep: true })
|
||||
|
||||
// 已选中的 key 不再出现在其他行的下拉中
|
||||
function availableKeys(idx) {
|
||||
const used = new Set(rows.value.map((r, i) => (i === idx ? null : r.key)).filter(Boolean))
|
||||
return OVERRIDE_KEYS.filter((k) => !used.has(k.key))
|
||||
}
|
||||
|
||||
function add() {
|
||||
rows.value.push({ key: '', value: 0 })
|
||||
}
|
||||
function remove(idx) {
|
||||
rows.value.splice(idx, 1)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.override-row { display: flex; gap: 8px; margin-bottom: 6px; align-items: center; }
|
||||
</style>
|
||||
47
put/web/src/components/ReviveEditor.vue
Normal file
47
put/web/src/components/ReviveEditor.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-alert type="warning" :closable="false" show-icon style="margin-bottom: 10px">
|
||||
revive 与 skills[1](大招)互斥;配置 revive 时按 lv1/lv9/lv16 三档成长。
|
||||
</el-alert>
|
||||
<div v-for="(r, idx) in model" :key="idx" class="skill-block">
|
||||
<div class="head">
|
||||
<span>复活档位 lv {{ r.lv }}</span>
|
||||
<el-button type="danger" size="small" text @click="model.splice(idx, 1)">删除档位</el-button>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="等级 lv">
|
||||
<el-input-number v-model="r.lv" :min="1" :max="20" :controls="false" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="复活技能 s_uuid">
|
||||
<el-select v-model="r.s_uuid" filterable>
|
||||
<el-option v-for="s in reviveOptions" :key="s.uuid" :label="`${s.uuid} ${s.name}`" :value="s.uuid" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="r_num 次数">
|
||||
<el-input-number v-model="r.r_num" :min="1" :controls="false" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="upr 复活血量百分比">
|
||||
<el-input-number v-model="r.upr" :min="0" :max="100" :controls="false" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名称(仅第1档)" v-if="idx === 0">
|
||||
<el-input v-model="r.name" placeholder="默认:凤凰涅槃" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" class="grid-span">
|
||||
<el-input v-model="r.desc" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
<el-button size="small" type="primary" @click="model.push({ lv: 1, s_uuid: 6501, r_num: 1, upr: 30, name: '', desc: '' })">+ 添加复活档位</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
model: { type: Array, required: true },
|
||||
reviveOptions: { type: Array, default: () => [] },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid-span { grid-column: 1 / -1; }
|
||||
</style>
|
||||
107
put/web/src/components/SkillEditor.vue
Normal file
107
put/web/src/components/SkillEditor.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="技能底座">
|
||||
<el-select v-model="skill.uuid" placeholder="选择技能" filterable @change="onUuidChange">
|
||||
<el-option v-for="s in options" :key="s.uuid" :label="`${s.uuid} ${s.name}`" :value="s.uuid">
|
||||
<span>{{ s.uuid }} {{ s.name }}</span>
|
||||
<span class="muted" style="float: right">{{ s.info }}</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="显示名">
|
||||
<el-input v-model="skill.name" placeholder="对外展示名(可选)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="攻速档位">
|
||||
<el-select v-model="cdSpeedKey" @change="onSpeedChange">
|
||||
<el-option v-for="s in ATK_SPEEDS" :key="s.key" :label="s.label" :value="s.key" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="CD(秒)">
|
||||
<el-input-number v-model="skill.cd" :step="0.05" :min="0" :controls="false" style="width: 100%" @change="onCdManual" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述模板" class="grid-span">
|
||||
<el-input v-model="skill.desc" type="textarea" :rows="2" placeholder="支持 {target}/{ap}/{para} 等占位符" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="overrides">
|
||||
<OverrideEditor v-model="skill.overrides" />
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="isUlt">
|
||||
<el-divider content-position="left">大招 lv_entries 分档</el-divider>
|
||||
<div v-for="(e, idx) in skill.lv_entries" :key="idx" class="skill-block">
|
||||
<div class="head">
|
||||
<span>档位 lv {{ e.lv }} (SkillLv.Lv{{ e.s_lv }})</span>
|
||||
<el-button type="danger" size="small" text @click="skill.lv_entries.splice(idx, 1)">移除</el-button>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="等级 lv">
|
||||
<el-input-number v-model="e.lv" :min="1" :max="20" :controls="false" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="SkillLv">
|
||||
<el-select v-model="e.s_lv">
|
||||
<el-option v-for="n in 5" :key="n" :label="`Lv${n}`" :value="n" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="overrides">
|
||||
<OverrideEditor v-model="e.overrides" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="e.desc" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-button size="small" @click="addEntry">+ 添加档位(lv9/lv16)</el-button>
|
||||
<span class="muted" style="margin-left: 8px">建议按 lv9 / lv16 两档</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { ATK_SPEEDS } from '../constants'
|
||||
import OverrideEditor from './OverrideEditor.vue'
|
||||
|
||||
const props = defineProps({
|
||||
skill: { type: Object, required: true },
|
||||
options: { type: Array, default: () => [] },
|
||||
isUlt: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const cdSpeedKey = refFromCd(props.skill)
|
||||
|
||||
function refFromCd(skill) {
|
||||
// 简单映射回攻速档
|
||||
return computed({
|
||||
get() {
|
||||
const found = ATK_SPEEDS.find((s) => Math.abs(cdOf(s.key) - skill.cd) < 0.001)
|
||||
return found?.key || 'Normal'
|
||||
},
|
||||
set() {},
|
||||
})
|
||||
}
|
||||
|
||||
function cdOf(key) {
|
||||
const map = { VeryFast: 0.75, Fast: 0.9, Normal: 1.05, Slow: 1.2, VerySlow: 1.5 }
|
||||
return map[key] ?? 1.05
|
||||
}
|
||||
|
||||
function onSpeedChange(key) {
|
||||
props.skill.cd = cdOf(key)
|
||||
props.skill.cdExpr = `AtkSpeedSet[AtkSpeedLv.${key}].cd`
|
||||
}
|
||||
function onCdManual() {
|
||||
props.skill.cdExpr = null
|
||||
}
|
||||
function onUuidChange() {
|
||||
// 选择底座时清空名字(回退默认)
|
||||
}
|
||||
function addEntry() {
|
||||
props.skill.lv_entries.push({ lv: 9, s_lv: 2, overrides: {}, desc: '' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid-span { grid-column: 1 / -1; }
|
||||
</style>
|
||||
72
put/web/src/components/TriggerGroupEditor.vue
Normal file
72
put/web/src/components/TriggerGroupEditor.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-for="(group, sid) in model" :key="sid" class="skill-block">
|
||||
<div class="head">
|
||||
<span>技能 {{ sid }} — {{ skillName(sid) }}</span>
|
||||
<el-button type="danger" size="small" text @click="removeGroup(sid)">删除组</el-button>
|
||||
</div>
|
||||
<div v-for="(entry, idx) in group" :key="idx" style="border-top: 1px dashed #eee; padding-top: 8px; margin-top: 8px">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="等级 lv">
|
||||
<el-input-number v-model="entry.lv" :min="1" :max="20" :controls="false" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="SkillLv">
|
||||
<el-select v-model="entry.s_lv">
|
||||
<el-option v-for="n in 5" :key="n" :label="`Lv${n}`" :value="n" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="t_num (1=每次,3≈33%,4≈25%)">
|
||||
<el-input-number v-model="entry.t_num" :min="1" :controls="false" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="组名(仅第1档)" v-if="idx === 0">
|
||||
<el-input v-model="entry.name" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="overrides">
|
||||
<OverrideEditor v-model="entry.overrides" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="entry.desc" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
<el-button type="danger" size="small" text @click="group.splice(idx, 1)">删除档位</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="addEntry(group)" style="margin-top: 6px">+ 添加档位</el-button>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; align-items: center">
|
||||
<el-select v-model="newSid" placeholder="选择触发技能" filterable style="width: 240px">
|
||||
<el-option v-for="s in skillOptions" :key="s.uuid" :label="`${s.uuid} ${s.name}`" :value="s.uuid" />
|
||||
</el-select>
|
||||
<el-button size="small" type="primary" @click="addGroup">+ 新增触发组</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import OverrideEditor from './OverrideEditor.vue'
|
||||
import { defaultTriggerEntry } from '../constants'
|
||||
|
||||
const props = defineProps({
|
||||
model: { type: Object, required: true }, // TriggerGrouped
|
||||
skillOptions: { type: Array, default: () => [] },
|
||||
skillMap: { type: Object, default: () => ({}) },
|
||||
})
|
||||
const newSid = ref(null)
|
||||
|
||||
function skillName(sid) {
|
||||
return props.skillMap[sid]?.name || ''
|
||||
}
|
||||
function addGroup() {
|
||||
if (!newSid.value) return
|
||||
if (!props.model[newSid.value]) props.model[newSid.value] = []
|
||||
props.model[newSid.value].push(defaultTriggerEntry(6))
|
||||
newSid.value = null
|
||||
}
|
||||
function removeGroup(sid) {
|
||||
delete props.model[sid]
|
||||
}
|
||||
function addEntry(group) {
|
||||
const lastLv = group.length ? group[group.length - 1].lv : 6
|
||||
group.push({ lv: lastLv + 3, s_lv: 2, name: '', t_num: 3, overrides: {}, desc: '' })
|
||||
}
|
||||
</script>
|
||||
117
put/web/src/constants.js
Normal file
117
put/web/src/constants.js
Normal file
@@ -0,0 +1,117 @@
|
||||
// 共享常量与工具
|
||||
export const HERO_ROLES = [
|
||||
{ key: 'Tank', label: '坦克', value: 0 },
|
||||
{ key: 'Warrior', label: '战士', value: 1 },
|
||||
{ key: 'Assassin', label: '刺客', value: 2 },
|
||||
{ key: 'Archer', label: '射手', value: 3 },
|
||||
{ key: 'Mage', label: '法师', value: 4 },
|
||||
{ key: 'Support', label: '辅助', value: 5 },
|
||||
]
|
||||
|
||||
export const GROW_TYPES = [
|
||||
{ key: 'Tank', label: '坦克', value: 0 },
|
||||
{ key: 'Warrior', label: '战士', value: 1 },
|
||||
{ key: 'Assassin', label: '刺客', value: 2 },
|
||||
{ key: 'Mage', label: '法师', value: 3 },
|
||||
{ key: 'Support', label: '辅助', value: 4 },
|
||||
{ key: 'Archer', label: '射手', value: 5 },
|
||||
]
|
||||
|
||||
export const H_TYPES = [
|
||||
{ key: 'Melee', label: '近战', value: 0 },
|
||||
{ key: 'Mid', label: '中程', value: 1 },
|
||||
{ key: 'Long', label: '远程', value: 2 },
|
||||
]
|
||||
|
||||
export const ATK_SPEEDS = [
|
||||
{ key: 'VeryFast', label: '极速(0.75s)', value: 1 },
|
||||
{ key: 'Fast', label: '快速(0.90s)', value: 2 },
|
||||
{ key: 'Normal', label: '中速(1.05s)', value: 3 },
|
||||
{ key: 'Slow', label: '慢速(1.20s)', value: 4 },
|
||||
{ key: 'VerySlow', label: '极慢(1.50s)', value: 5 },
|
||||
]
|
||||
|
||||
// 按职业映射普攻底座
|
||||
export const ROLE_ATK_SKILL = {
|
||||
Tank: [6004],
|
||||
Warrior: [6004],
|
||||
Assassin: [6001, 6002, 6003],
|
||||
Archer: [6006, 6007, 6008],
|
||||
Mage: [6009, 6010, 6011, 6012],
|
||||
Support: [6009, 6010, 6011, 6012],
|
||||
}
|
||||
|
||||
// 按职业映射大招底座
|
||||
export const ROLE_ULT_SKILL = {
|
||||
Tank: [6100, 6101, 6102, 6103, 6104],
|
||||
Warrior: [6100, 6101, 6102, 6103, 6104],
|
||||
Assassin: [6150, 6151],
|
||||
Archer: [6150, 6151],
|
||||
Mage: [6200, 6201, 6202, 6203, 6204],
|
||||
Support: [6200, 6201, 6202, 6203, 6204],
|
||||
}
|
||||
|
||||
// 辅助类技能(任意职业可用作大招)
|
||||
export const SUPPORT_ULT = [6301, 6302, 6412, 6501]
|
||||
|
||||
export function defaultHero() {
|
||||
return {
|
||||
uuid: 5001,
|
||||
name: '',
|
||||
comment: '',
|
||||
path: '',
|
||||
rarity: 4,
|
||||
roleKey: 'Mage',
|
||||
growKey: 'Mage',
|
||||
typeKey: 'Long',
|
||||
statBase: { hp: 0, ap: 0, def: 0 },
|
||||
dis: null,
|
||||
speed: null,
|
||||
skills: [], // [{uuid,name,cd,cdExpr,overrides,desc,lv_entries:[{lv,s_lv,overrides,desc}]}]
|
||||
atking: {}, // {uuid:[{lv,s_lv,name,t_num,overrides,desc}]}
|
||||
atked: {},
|
||||
fstart: {},
|
||||
dead: {},
|
||||
field: [], // [{lv,uuids:[{uuid,value,name}],desc}]
|
||||
revive: [], // [{lv,s_uuid,r_num,upr,name,desc}]
|
||||
ap_bonus: [],
|
||||
hp_bonus: [],
|
||||
def_bonus: [],
|
||||
info: '',
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultSkill(uuid) {
|
||||
return {
|
||||
uuid,
|
||||
name: '',
|
||||
cd: 1.05,
|
||||
cdExpr: 'AtkSpeedSet[AtkSpeedLv.Normal].cd',
|
||||
overrides: {},
|
||||
desc: '',
|
||||
lv_entries: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultTriggerEntry(lv = 6) {
|
||||
return { lv, s_lv: 1, name: '', t_num: 3, overrides: {}, desc: '' }
|
||||
}
|
||||
|
||||
// overrides 可用键说明
|
||||
export const OVERRIDE_KEYS = [
|
||||
{ key: 'ap', label: 'ap 攻击百分比' },
|
||||
{ key: 'hit_count', label: 'hit_count 命中次数' },
|
||||
{ key: 'hitcd', label: 'hitcd 持续伤害间隔' },
|
||||
{ key: 'crt', label: 'crt 暴击率' },
|
||||
{ key: 'frz', label: 'frz 冰冻概率' },
|
||||
{ key: 'stun', label: 'stun 击晕概率' },
|
||||
{ key: 'para', label: 'para 麻痹概率' },
|
||||
{ key: 'bck', label: 'bck 击退' },
|
||||
{ key: 'bck_chance', label: 'bck_chance 击退概率' },
|
||||
{ key: 'timed_buff_id', label: 'timed_buff_id 计时buff' },
|
||||
{ key: 'buff_value', label: 'buff_value 修饰数值' },
|
||||
{ key: 'buff_duration', label: 'buff_duration 持续时间' },
|
||||
{ key: 'summon_count', label: 'summon_count 召唤数' },
|
||||
{ key: 'num', label: 'num 额外释放次数' },
|
||||
{ key: 'call_hero', label: 'call_hero 召唤英雄id' },
|
||||
]
|
||||
10
put/web/src/main.js
Normal file
10
put/web/src/main.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
app.mount('#app')
|
||||
137
put/web/src/style.css
Normal file
137
put/web/src/style.css
Normal file
@@ -0,0 +1,137 @@
|
||||
:root {
|
||||
--brand: #4a90e2;
|
||||
--brand-light: #7fb3f0;
|
||||
--bg: #f5f9ff;
|
||||
--panel: #ffffff;
|
||||
--border: #e3ecf7;
|
||||
--text: #2c3e50;
|
||||
--text-light: #7a8ba0;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #app { height: 100%; margin: 0; }
|
||||
body {
|
||||
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.hero-app {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hero-header {
|
||||
background: linear-gradient(135deg, #6fb1ff 0%, #4a90e2 100%);
|
||||
color: #fff;
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 2px 8px rgba(74, 144, 226, 0.25);
|
||||
}
|
||||
.hero-header h1 { font-size: 18px; margin: 0; font-weight: 600; }
|
||||
.hero-header .actions { display: flex; gap: 8px; }
|
||||
|
||||
.hero-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-sidebar {
|
||||
width: 260px;
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.hero-sidebar .side-head {
|
||||
padding: 12px 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.hero-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
}
|
||||
.hero-item {
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.hero-item:hover { background: #eef5ff; }
|
||||
.hero-item.active { background: #d6e8ff; color: var(--brand); font-weight: 600; }
|
||||
.hero-item .meta { font-size: 12px; color: var(--text-light); }
|
||||
|
||||
.hero-editor {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px 40px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 1px 3px rgba(46, 91, 160, 0.05);
|
||||
}
|
||||
.panel h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
color: var(--brand);
|
||||
border-left: 3px solid var(--brand);
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 10px 18px;
|
||||
}
|
||||
|
||||
.muted { color: var(--text-light); font-size: 12px; }
|
||||
|
||||
.skill-block {
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
background: #fbfdff;
|
||||
}
|
||||
.skill-block .head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.override-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--text-light);
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero-sidebar { width: 200px; }
|
||||
}
|
||||
15
put/web/vite.config.js
Normal file
15
put/web/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user