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 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 } /** 生成英雄 TS 条目(供 /api/apply 使用) */ 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},`) if (h.dodge != null) push(` dodge: ${h.dodge},`) if (h.accuracy != null) push(` accuracy: ${h.accuracy},`) // 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' } /** * 直接写回 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.thorns_dmg != null) parts.push(`thorns${ov.thorns_dmg}`) if (ov.thorns_rate != null) parts.push(`thorns${ov.thorns_rate}%`) 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}`) } // proc_skill:命中后概率触发额外技能,标注被触发技能名/概率/覆写 ap(若有) if (ov.proc_skill?.s_uuid != null) { const procName = cache?.SkillSet?.[ov.proc_skill.s_uuid]?.name || '' const procChance = ov.proc_skill.chance != null ? `${ov.proc_skill.chance}%` : '100%' const procAp = ov.proc_skill.overrides?.ap != null ? `ap${ov.proc_skill.overrides.ap}` : '' parts.push(`触发${procName}${ov.proc_skill.s_uuid}(${procChance}${procAp ? `;${procAp}` : ''})`) } 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(['dodge', ...sorted.map((h) => (h.dodge != null ? h.dodge : ''))]) rows.push(['accuracy', ...sorted.map((h) => (h.accuracy != null ? h.accuracy : ''))]) 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')) }) /** HeroList 定义匹配(用于导出时同步图鉴/卡池数据源) */ const HERO_LIST_RE = /export const HeroList: number\[\] = \[[\s\S]*?\n\];/ /** 按导出英雄 uuid 升序重建 HeroList(每行 10 个,含去重),避免编辑器增删英雄后列表不同步 */ function syncHeroList(src, heroes) { const uuids = [...new Set(heroes.map((h) => h.uuid))].sort((a, b) => a - b) if (!uuids.length) return src const lines = [] for (let i = 0; i < uuids.length; i += 10) { lines.push(' ' + uuids.slice(i, i + 10).join(', ') + ',') } const block = `export const HeroList: number[] = [\n` + ` // 编辑器导出自动同步(共 ${uuids.length} 个)\n` + `${lines.join('\n')}\n` + `];` if (!HERO_LIST_RE.test(src)) { throw new Error('heroSet.ts 中未找到 HeroList 定义,无法同步') } return src.replace(HERO_LIST_RE, block) } 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('') let next = src.slice(0, bi) + HEROES_BEGIN + '\n' + body + HEROES_END + src.slice(ei + HEROES_END.length) // 同步 HeroList:图鉴展示与抽卡池(HeroCardPoolList)均由该列表驱动 next = syncHeroList(next, heroes) 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}`) })