Files
pixelheros/put/server/index.js
panFD b63572c383 feat(trigger): add 回合结束触发组支持
1. 新增fend触发组常量与默认英雄模板配置
2. 后端校验、渲染逻辑兼容新触发组
3. 更新文档说明fend触发组规范
4. 前端UI添加回合结束触发组编辑器
5. 兼容旧英雄数据补全fend空对象
2026-09-14 17:09:31 +08:00

493 lines
20 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 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) })
}
})
const TEMPLATES_PATH = path.join(DATA_DIR, 'templates.json')
/** 预设库持久化:GET 读取(无文件时返回空池) */
app.get('/api/templates', (_req, res) => {
try {
if (!fs.existsSync(TEMPLATES_PATH)) return res.json({ triggerPresets: [], fieldPresets: [] })
const data = JSON.parse(fs.readFileSync(TEMPLATES_PATH, 'utf8'))
// 兼容旧格式:若根上是 templates 数组,返回空池让前端走默认
if (Array.isArray(data.templates)) return res.json({ triggerPresets: [], fieldPresets: [] })
res.json({
triggerPresets: Array.isArray(data.triggerPresets) ? data.triggerPresets : [],
fieldPresets: Array.isArray(data.fieldPresets) ? data.fieldPresets : [],
})
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
})
/** 职业模板持久化:POST 全量覆盖保存(结构:{ triggerPresets: [], fieldPresets: [] }) */
app.post('/api/templates', (req, res) => {
const body = req.body || {}
const triggerPresets = body.triggerPresets
const fieldPresets = body.fieldPresets
if (!Array.isArray(triggerPresets) || !Array.isArray(fieldPresets)) {
return res.status(400).json({ error: 'triggerPresets 与 fieldPresets 必须为数组' })
}
// 触发预设必须绑定合法触发组
const groups = ['atking', 'atked', 'fstart', 'fend', 'dead']
const bad = triggerPresets.find((p) => !groups.includes(p.group))
if (bad) {
return res.status(400).json({ error: `触发预设 ${bad.id || bad.label} 的 group 非法:${bad.group}` })
}
try {
fs.mkdirSync(DATA_DIR, { recursive: true })
fs.writeFileSync(TEMPLATES_PATH, JSON.stringify({ triggerPresets, fieldPresets }, null, 2), 'utf8')
res.json({ ok: true, path: TEMPLATES_PATH, triggerCount: triggerPresets.length, fieldCount: fieldPresets.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', 'fend', '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 !== 1 ? `→${BUFF_TARGET_NAMES[ov.buff_target]}` : ''
parts.push(`附加${val}${buffName}${ov.timed_buff_id}${chance}${tgtLabel}`)
}
// timed_debuff_id:命中被攻击者后施加(目标固定,无需标注)
if (ov.timed_debuff_id != null) {
const debuffName = cache?.BuffList?.[ov.timed_debuff_id]?.name || ''
const val = ov.debuff_value != null ? `${ov.debuff_value}` : ''
const chance = ov.debuff_chance != null ? `debuff_chance${ov.debuff_chance}` : ''
parts.push(`命中附加${val}${debuffName}${ov.timed_debuff_id}${chance}`)
}
// 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(['fend[0]_lv4', ...sorted.map((h) => csvTrigger(h.fend))])
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}`)
})