本次变更完成了英雄bonus属性加成系统的图鉴可视化改造: 1. 扩展LvBonusAttrsEntry类型,新增name/icon/info配置字段 2. 服务端渲染兼容新增字段,仅在配置存在时输出 3. 更新英雄配置文档,补充bonus字段的新参数说明 4. 新增属性加成编辑器表单,支持配置名称、图标和自定义描述 5. 实现英雄图鉴的bonus档位图标渲染逻辑,支持锁定状态和点击弹窗 6. 新增bonus效果文案生成工具,支持自定义模板和属性自动生成 7. 为技能弹窗新增bonus专属渲染逻辑,适配属性加成档位的展示 8. 为部分测试英雄配置了bonus档位的名称属性
444 lines
18 KiB
JavaScript
444 lines
18 KiB
JavaScript
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,
|
||
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 字段
|
||
|
||
// bonus(统一额外属性加成:hp/ap/def 百分比 + crt/frz/stun/para 百分点;跳过空 attrs 档位)
|
||
const bonusList = (h.bonus || []).filter((b) => b.attrs && Object.keys(b.attrs).length)
|
||
if (bonusList.length) {
|
||
push(` bonus: [`)
|
||
for (const b of bonusList) {
|
||
// 展示字段(name/icon/info)非空才输出,保持底座纯净
|
||
const nameSeg = b.name ? ` name: ${JSON.stringify(b.name)},` : ''
|
||
const iconSeg = b.icon ? ` icon: ${JSON.stringify(b.icon)},` : ''
|
||
const infoSeg = b.info ? ` info: ${JSON.stringify(b.info)},` : ''
|
||
push(` { lv: ${b.lv}, attrs: ${JSON.stringify(b.attrs)},${nameSeg}${iconSeg}${infoSeg} },`)
|
||
}
|
||
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}`
|
||
}
|
||
|
||
/** bonus 描述行:lv9 ap+20% hp+15%;lv12 crt+10 ap+30% */
|
||
function csvBonus(bonus) {
|
||
if (!bonus?.length) return '无'
|
||
return bonus
|
||
.filter((b) => b.attrs && Object.keys(b.attrs).length)
|
||
.map((b) => {
|
||
const parts = Object.entries(b.attrs).map(([k, v]) => (k === 'hp' || k === 'ap' || k === 'def') ? `${k}+${v}%` : `${k}+${v}`)
|
||
return `lv${b.lv} ${parts.join(' ')}`
|
||
})
|
||
.join(';') || '无'
|
||
}
|
||
|
||
/** 生成英雄设计表 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))])
|
||
rows.push(['bonus', ...sorted.map((h) => csvBonus(h.bonus))])
|
||
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}`)
|
||
})
|