feat(web+server): 新增枚举覆写支持与CSV导出功能

1. 新增buff_target枚举覆写配置,支持选择施法目标类型
2. 后端添加CSV导出接口,生成标准化英雄设计表
3. 前端新增CSV导出按钮,支持一键导出配置
4. 优化覆写字段渲染逻辑,支持枚举值转可读枚举名
5. 清理英雄数据中冗余的summon_count默认配置
This commit is contained in:
panFD
2026-09-05 12:23:23 +08:00
parent 071afc8fa2
commit 1806f4b1a5
7 changed files with 211 additions and 40 deletions

View File

@@ -10,6 +10,7 @@ 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 HEROSET_PATH = path.join(CONFIG_DIR, 'heroSet.ts')
const CSV_PATH = path.join(CONFIG_DIR, '英雄设计表.csv')
// heroSet.ts 中英雄条目区域标记(怪物配置在 end 标记之后,不受替换影响)
const HEROES_BEGIN = '// @hero-setup:heroes-begin'
const HEROES_END = '// @hero-setup:heroes-end'
@@ -104,9 +105,13 @@ app.post('/api/heroes', (req, res) => {
}
})
/** 序列化 overrides:TGroup 以字符串存储(如 "Team"),导出时还原为枚举表达式 TGroup.Team */
/** 序列化 overrides:TGroup 以字符串存储(如 "Team"),导出时还原为枚举表达式 TGroup.Team;
* buff_target 以数值存储(0-3),导出时还原为 BuffTarget.X 枚举表达式提升可读性 */
const BUFF_TARGET_NAMES = ['Hit', 'Self', 'Ally', 'Enemy']
function serializeOverrides(o) {
return JSON.stringify(o).replace(/"TGroup":\s*"(\w+)"/g, '"TGroup": TGroup.$1')
let s = JSON.stringify(o).replace(/"TGroup":\s*"(\w+)"/g, '"TGroup": TGroup.$1')
s = s.replace(/"buff_target":\s*([0-3])\b/g, (_, n) => `"buff_target": BuffTarget.${BUFF_TARGET_NAMES[+n]}`)
return s
}
/** 生成 hexport.ts 内容 */
@@ -120,7 +125,7 @@ function renderHeroTs(heroes) {
out += ` */${nl}`
out += `import { v3 } from "cc"${nl}`
out += `import { FacSet } from "./GameSet"${nl}`
out += `import { TGroup } from "./SkillSet"${nl}`
out += `import { TGroup, BuffTarget } from "./SkillSet"${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}`
@@ -254,6 +259,150 @@ app.get('/api/export', (_req, res) => {
* 直接写回 heroSet.ts:用编辑器数据替换英雄条目区域(HEROES_BEGIN/END 标记之间),
* 怪物条目与区域外的其它配置保持不变。
*/
const ROLE_CSV_KEY = { Tank: 'tank', Warrior: 'warrior', Assassin: 'assassin', Archer: 'archer', Mage: 'mage', Support: 'support' }
const TYPE_CSV_LABEL = { Melee: '近战', Mid: '中程', Long: '远程' }
// 攻速 cd 值 → CSV 档位描述
const CD_SPEED_LABELS = [[0.75, '极速0.75s'], [0.9, '快速0.90s'], [1.05, '中速1.05s'], [1.2, '慢速1.20s'], [1.5, '极慢1.50s']]
function cdSpeedLabel(cd) {
const hit = CD_SPEED_LABELS.find(([v]) => Math.abs(v - cd) < 0.001)
return hit ? hit[1] : `攻速${cd}s`
}
// 字段值转 CSV 单元格(含逗号/换行时加引号)
function csvCell(v) {
const s = String(v ?? '')
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
}
/** 覆写值转 CSV 简写:ap120 / stun10 / crt10 / bck10 / frz30 / para50 / summon2 / 附加易伤7002 */
function overrideShort(ov, { includeAp = true } = {}) {
if (!ov) return ''
const parts = []
if (includeAp && ov.ap != null) parts.push(`ap${ov.ap}`)
if (ov.crt != null) parts.push(`crt${ov.crt}`)
if (ov.stun != null) parts.push(`stun${ov.stun}`)
if (ov.para != null) parts.push(`para${ov.para}`)
if (ov.frz != null) parts.push(`frz${ov.frz}`)
if (ov.bck != null) parts.push(`bck${ov.bck}`)
if (ov.summon_count != null && ov.summon_count > 1) parts.push(`summon${ov.summon_count}`)
if (ov.timed_buff_id != null) {
const buffName = cache?.BuffList?.[ov.timed_buff_id]?.name || ''
const val = ov.buff_value != null ? `${ov.buff_value}` : ''
const chance = ov.buff_chance != null ? `buff_chance${ov.buff_chance}` : ''
// buff_target 非默认(被命中者)时标注目标,便于识别"挂自己/挂敌方"等跨阵营 buff
const tgtLabel = ov.buff_target != null && ov.buff_target !== 0 ? `→${BUFF_TARGET_NAMES[ov.buff_target]}` : ''
parts.push(`附加${val}${buffName}${ov.timed_buff_id}${chance}${tgtLabel}`)
}
return parts.join(';')
}
/** 技能 lv1 描述行:6004 攻击(ap100;慢速1.20s) */
function csvSkillLv1(sk, isUlt) {
if (!sk) return ''
const base = cache?.SkillSet?.[sk.uuid]
const name = sk.name || base?.name || ''
const ov = sk.overrides || {}
const parts = []
if (ov.ap != null) parts.push(`ap${ov.ap}`)
if (!isUlt) parts.push(cdSpeedLabel(sk.cd ?? 1.05))
const extra = overrideShort(ov, { includeAp: false })
if (extra) parts.push(extra)
return `${sk.uuid} ${name}(${parts.join(';')})`
}
/** 技能升档描述行:ap120 / stun10 / bck10 / 附加30%出血7007buff_chance30 */
function csvSkillLvUp(sk, lv) {
if (!sk) return ''
const entry = (sk.lv_entries || []).find((e) => e.lv === lv)
if (!entry) return ''
return overrideShort(entry.overrides || {})
}
/** 触发技能描述行:6004 奋力一击(t_num3;ap300;慢速1.20s):每三次攻击后立即打击敌人,对敌人造成300%伤害 */
function csvTrigger(grp) {
if (!grp) return '无'
for (const [sid, entries] of Object.entries(grp)) {
const e = entries?.[0]
if (!e) continue
const base = cache?.SkillSet?.[sid]
const name = e.name || base?.name || ''
const ov = e.overrides || {}
const parts = [`t_num${e.t_num}`]
if (ov.ap != null) parts.push(`ap${ov.ap}`)
const desc = e.desc || ''
return `${sid} ${name}(${parts.join(';')}):${desc}`
}
return '无'
}
/** 光环描述行:7005 暴伤光环0.25 */
function csvField(field) {
if (!field?.length) return '无'
const f = field[0]
const parts = f.uuids.map((u) => {
const base = cache?.FieldSkillSet?.[u.uuid]
const name = u.name || base?.name || ''
return `${u.uuid} ${name}${u.value}`
})
return parts.join('+')
}
/** 英雄基础三维行:tank+20 / warrior+20 / archer */
function csvStatLine(h, key) {
const role = ROLE_CSV_KEY[h.roleKey] || h.roleKey || ''
const base = h.statBase?.[key] ?? 0
if (base === 0) return role
return `${role}${base > 0 ? '+' : ''}${base}`
}
/** 生成英雄设计表 CSV 内容 */
function renderHeroCsv(heroes) {
const sorted = [...heroes].sort((a, b) => a.uuid - b.uuid)
const rows = []
rows.push(['字段', ...sorted.map((h) => h.uuid)])
rows.push(['英雄名称', ...sorted.map((h) => h.name || '')])
rows.push(['简化说明', ...sorted.map((h) => h.comment || '')])
rows.push(['rarity', ...sorted.map((h) => h.rarity ?? 1)])
rows.push(['类型', ...sorted.map((h) => ROLE_CSV_KEY[h.roleKey] || h.roleKey || '')])
rows.push(['形态', ...sorted.map((h) => TYPE_CSV_LABEL[h.typeKey] || h.typeKey || '')])
rows.push(['ap', ...sorted.map((h) => csvStatLine(h, 'ap'))])
rows.push(['hp', ...sorted.map((h) => csvStatLine(h, 'hp'))])
rows.push(['def', ...sorted.map((h) => csvStatLine(h, 'def'))])
rows.push(['skill[0]_lv1', ...sorted.map((h) => csvSkillLv1(h.skills?.[0], false))])
rows.push(['skill[0]_lv2', ...sorted.map((h) => csvSkillLvUp(h.skills?.[0], 2))])
rows.push(['skill[1]_lv1', ...sorted.map((h) => csvSkillLv1(h.skills?.[1], true))])
rows.push(['skill[1]_lv8', ...sorted.map((h) => csvSkillLvUp(h.skills?.[1], 8))])
rows.push(['skill[1]_lv16', ...sorted.map((h) => csvSkillLvUp(h.skills?.[1], 16))])
rows.push(['atking[0]_lv4', ...sorted.map((h) => csvTrigger(h.atking))])
rows.push(['atked[0]_lv4', ...sorted.map((h) => csvTrigger(h.atked))])
rows.push(['fstart[0]_lv4', ...sorted.map((h) => csvTrigger(h.fstart))])
rows.push(['dead[0]_lv4', ...sorted.map((h) => csvTrigger(h.dead))])
rows.push(['field[0]_lv4', ...sorted.map((h) => csvField(h.field))])
return rows.map((r) => r.map(csvCell).join(',')).join('\n') + '\n'
}
app.post('/api/export-csv', async (req, res) => {
try {
const heroes = req.body?.heroes
if (!Array.isArray(heroes) || !heroes.length) {
return res.status(400).json({ error: 'heroes 不能为空' })
}
if (!cache) await buildCache()
const content = renderHeroCsv(heroes)
fs.writeFileSync(CSV_PATH, content, 'utf8')
res.json({ ok: true, path: CSV_PATH, count: heroes.length, bytes: Buffer.byteLength(content) })
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
})
app.get('/api/export-csv', (_req, res) => {
if (!fs.existsSync(CSV_PATH)) return res.status(404).json({ error: '尚未生成' })
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
res.send(fs.readFileSync(CSV_PATH, 'utf8'))
})
app.post('/api/apply', (req, res) => {
try {
const heroes = req.body?.heroes