Files
pixelheros/put/server/index.js
panFD 1806f4b1a5 feat(web+server): 新增枚举覆写支持与CSV导出功能
1. 新增buff_target枚举覆写配置,支持选择施法目标类型
2. 后端添加CSV导出接口,生成标准化英雄设计表
3. 前端新增CSV导出按钮,支持一键导出配置
4. 优化覆写字段渲染逻辑,支持枚举值转可读枚举名
5. 清理英雄数据中冗余的summon_count默认配置
2026-09-05 12:23:23 +08:00

432 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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'
const DATA_DIR = path.join(__dirname, 'data')
const HEROES_PATH = path.join(DATA_DIR, 'heroes.json')
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) })
}
})
/** 英雄数据持久化:GET 读取(无文件时返回空列表) */
app.get('/api/heroes', (_req, res) => {
try {
if (!fs.existsSync(HEROES_PATH)) return res.json({ heroes: [] })
res.json(JSON.parse(fs.readFileSync(HEROES_PATH, 'utf8')))
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
})
/** 英雄数据持久化:POST 全量覆盖保存 */
app.post('/api/heroes', (req, res) => {
const heroes = req.body?.heroes
if (!Array.isArray(heroes)) {
return res.status(400).json({ error: 'heroes 必须为数组' })
}
try {
fs.mkdirSync(DATA_DIR, { recursive: true })
fs.writeFileSync(HEROES_PATH, JSON.stringify({ heroes }, null, 2), 'utf8')
res.json({ ok: true, path: HEROES_PATH, count: heroes.length })
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
})
/** 序列化 overrides:TGroup 以字符串存储(如 "Team"),导出时还原为枚举表达式 TGroup.Team;
* buff_target 以数值存储(0-3),导出时还原为 BuffTarget.X 枚举表达式提升可读性 */
const BUFF_TARGET_NAMES = ['Hit', 'Self', 'Ally', 'Enemy']
function serializeOverrides(o) {
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 内容 */
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 { 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}`
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 ? ` — ${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: ${serializeOverrides(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: ${serializeOverrides(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: ${serializeOverrides(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(` ],`)
}
// 复活已纳入 dead 触发组,通过 dead 编辑器的 overrides 配置 revive_hp / revive_count
// 不再导出独立的 revive 字段
// 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'))
})
/**
* 直接写回 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
if (!Array.isArray(heroes) || !heroes.length) {
return res.status(400).json({ error: 'heroes 不能为空' })
}
const src = fs.readFileSync(HEROSET_PATH, 'utf8')
const bi = src.indexOf(HEROES_BEGIN)
const ei = src.indexOf(HEROES_END)
if (bi < 0 || ei < 0 || ei < bi) {
return res.status(500).json({ error: 'heroSet.ts 中未找到英雄区域标记(@hero-setup:heroes-begin/end)' })
}
const body = heroes.map(renderSingleHero).join('')
const next = src.slice(0, bi) + HEROES_BEGIN + '\n' + body + HEROES_END + src.slice(ei + HEROES_END.length)
fs.writeFileSync(HEROSET_PATH, next, 'utf8')
res.json({ ok: true, path: HEROSET_PATH, count: heroes.length, bytes: Buffer.byteLength(next) })
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
})
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}`)
})