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

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>