feat(editor): 完成英雄配置编辑器全功能开发

本提交新增完整的英雄配置可视化编辑器:
1.  初始化前后端项目结构,配置monorepo工作区
2.  实现后端API服务,支持读取游戏配置、导出hexport.ts文件
3.  开发前端Vue应用,包含完整的英雄增删改、属性配置、技能编辑功能
4.  实现多类型编辑器组件:基础属性、普攻/大招编辑、触发技能、光环、复活、属性加成等
5.  清理冗余的配置元数据文件
This commit is contained in:
panFD
2026-09-02 23:35:40 +08:00
parent 6fed56d3c7
commit a2dc40997c
28 changed files with 4641 additions and 66 deletions

4
put/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
*.local
.DS_Store

3380
put/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
put/package.json Normal file
View 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
View 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
View 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"
}
}

View 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
View 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
View 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
View 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
View 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),
}

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

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

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

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

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

View 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
View 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
View 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
View 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
View 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,
},
},
},
})