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 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 */ function serializeOverrides(o) { return JSON.stringify(o).replace(/"TGroup":\s*"(\w+)"/g, '"TGroup": TGroup.$1') } /** 生成 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 } 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 = {${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: ${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')) }) 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}`) })