Files
pixelheros/put/web/src/App.vue
pan cd9e7296a1 feat(trigger-editor): 添加触发组概率与保底配置功能
1. 新增触发组条目字段t_chance(触发概率)和pity_add(保底叠加),仅攻击/受击组生效
2. 为TriggerGroupEditor传入分组标识,动态显示概率配置表单项
3. 新增条目创建时继承同组末档概率参数的逻辑
4. 更新常量定义与文档说明,补充概率参数使用规则
5. 在前端展示和服务端导出中添加概率相关信息展示
2026-09-21 16:01:41 +08:00

557 lines
23 KiB
Vue
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.
<template>
<div class="hero-app">
<header class="hero-header">
<h1>英雄配置编辑器</h1>
<div class="actions">
<el-radio-group v-model="view" size="small">
<el-radio-button value="heroes">英雄列表</el-radio-button>
<el-radio-button value="templates">预设库</el-radio-button>
</el-radio-group>
<el-button size="small" @click="refreshMeta" :loading="loadingMeta">刷新配置数据</el-button>
<el-button size="small" type="success" @click="doSave" :disabled="!heroes.length">保存到服务器</el-button>
<el-button size="small" type="primary" @click="doApply" :disabled="!heroes.length">写入 heroSet.ts</el-button>
<el-button size="small" type="danger" @click="doExportCsv" :disabled="!heroes.length">导出英雄设计表.csv</el-button>
</div>
</header>
<div class="hero-main">
<!-- 预设库视图 -->
<TemplateManager
v-if="view === 'templates'"
:trigger-presets="triggerPresets"
:field-presets="fieldPresets"
:skill-options="allSkills"
:skill-map="skillMap"
:field-options="fieldOptions"
:buff-options="buffOptions"
/>
<template v-else>
<!-- 首页:英雄卡片列表 -->
<div class="home-toolbar">
<span class="muted">共 {{ heroes.length }} 个英雄</span>
<div class="toolbar-sort">
<el-radio-group v-model="sortKey" size="small">
<el-radio-button value="none">默认</el-radio-button>
<el-radio-button value="role">按职业</el-radio-button>
<el-radio-button value="rarity">按星级</el-radio-button>
</el-radio-group>
<el-radio-group v-if="sortKey !== 'none'" v-model="sortOrder" size="small">
<el-radio-button value="asc">升序</el-radio-button>
<el-radio-button value="desc">降序</el-radio-button>
</el-radio-group>
</div>
<el-button size="small" type="primary" @click="addHero">+ 新建英雄</el-button>
</div>
<section class="hero-rows">
<div v-if="!heroes.length" class="empty">暂无英雄,点击右上角「新建英雄」开始配置</div>
<div v-for="{ h, i } in sortedHeroes" :key="h.uuid" class="hero-row" @click="openEdit(i)">
<div class="row-main">
<span class="row-uuid">{{ h.uuid }}</span>
<span class="row-name">{{ h.name || '(未命名)' }}</span>
<div class="row-sub">
<span class="row-role" :class="`role-${h.roleKey?.toLowerCase()}`">{{ roleLabel(h.roleKey) }}</span>
<span class="card-rarity">{{ '★'.repeat(h.rarity) }}</span>
</div>
</div>
<div class="row-meta">
<template v-if="heroStats(h)">
<span class="stat">HP {{ heroStats(h).hp }}</span>
<span class="stat">AP {{ heroStats(h).ap }}</span>
<span class="stat">DEF {{ heroStats(h).def }}</span>
</template>
</div>
<div class="row-skills">
<div class="row-skill">
<span class="tag">普攻</span>{{ skillLabel(h.skills?.[0]) }}
<span class="skill-detail">{{ skillDetailFull(h.skills?.[0]) }}</span>
</div>
<div class="row-skill">
<span class="tag">大招</span>{{ skillLabel(h.skills?.[1]) }}
<span class="skill-detail">{{ skillDetailFull(h.skills?.[1]) }}</span>
</div>
</div>
<div class="row-triggers" v-if="hasTriggers(h) || fieldSummary(h).length">
<span v-if="fieldSummary(h).length" class="trigger-chip aura">光环: {{ fieldSummary(h).join(';') }}</span>
<span v-for="line in triggerSummary(h)" :key="line" class="trigger-chip">{{ line }}</span>
<span v-if="bonusCount(h)" class="trigger-chip">加成: ×{{ bonusCount(h) }}</span>
</div>
<!-- 英雄简介:整行展示,超长省略,悬停查看全文 -->
<div class="row-info" v-if="h.info" :title="h.info">{{ h.info }}</div>
</div>
</section>
<!-- 编辑弹窗 -->
<el-dialog
v-model="editVisible"
:title="`编辑英雄 ${current?.uuid || ''} ${current?.name || ''}`"
width="94%"
top="3vh"
class="editor-dialog"
:close-on-click-modal="false"
>
<section class="hero-editor" v-if="current">
<!-- 基础属性 -->
<div class="panel">
<h3>基础属性</h3>
<div class="form-grid">
<el-form-item label="uuid">
<el-input-number v-model="current.uuid" :min="5001" :controls="false" style="width: 100%" />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="current.name" />
</el-form-item>
<el-form-item label="资源路径 path">
<el-input v-model="current.path" placeholder="hm1 / hk2 ..." />
</el-form-item>
<el-form-item label="品质 rarity">
<el-select v-model="current.rarity">
<el-option v-for="n in 5" :key="n" :label="`${n} 星`" :value="n" />
</el-select>
</el-form-item>
<el-form-item label="职业 role">
<el-select v-model="current.roleKey">
<el-option v-for="r in HERO_ROLES" :key="r.key" :label="r.label" :value="r.key" />
</el-select>
</el-form-item>
<el-form-item label="成长 grow_type">
<el-select v-model="current.growKey">
<el-option v-for="g in GROW_TYPES" :key="g.key" :label="g.label" :value="g.key" />
</el-select>
</el-form-item>
<el-form-item label="形态 type">
<el-select v-model="current.typeKey">
<el-option v-for="t in H_TYPES" :key="t.key" :label="t.label" :value="t.key" />
</el-select>
</el-form-item>
<el-form-item label="攻击距离 dis(可选)">
<el-input-number v-model="current.dis" :min="0" :controls="false" style="width: 100%" placeholder="默认按形态" />
</el-form-item>
<el-form-item label="闪避 dodge(可选)">
<el-input-number v-model="current.dodge" :min="0" :max="100" :controls="false" style="width: 100%" placeholder="默认 15" />
</el-form-item>
<el-form-item label="命中 accuracy(可选)">
<el-input-number v-model="current.accuracy" :min="0" :max="100" :controls="false" style="width: 100%" placeholder="默认 10" />
</el-form-item>
<el-form-item label="hp 偏移">
<el-input-number v-model="current.statBase.hp" :controls="false" style="width: 100%" />
</el-form-item>
<el-form-item label="ap 偏移">
<el-input-number v-model="current.statBase.ap" :controls="false" style="width: 100%" />
</el-form-item>
<el-form-item label="def 偏移">
<el-input-number v-model="current.statBase.def" :controls="false" style="width: 100%" />
</el-form-item>
<el-form-item label="注释">
<el-input v-model="current.comment" placeholder="导出文件中显示在条目上方" />
</el-form-item>
</div>
<div class="muted">基础值 = HERO_ROLE_BASE[{{ current.roleKey }}] → hp {{ roleBase?.hp }} / ap {{ roleBase?.ap }} / def {{ roleBase?.def }}</div>
</div>
<!-- 普攻 -->
<div class="panel">
<h3>普攻 skills[0]</h3>
<SkillEditor v-if="basicSkill" :skill="basicSkill" :options="basicSkillOptions" :skill-options="allSkills" :buff-options="buffOptions" :basic-lv2="true" />
<el-button v-else size="small" type="primary" @click="addBasicSkill">+ 添加普攻</el-button>
</div>
<!-- 大招 -->
<div class="panel">
<h3>大招 skills[1]</h3>
<SkillEditor v-if="ultSkill" :skill="ultSkill" :options="ultSkillOptions" :skill-options="allSkills" :buff-options="buffOptions" :is-ult="true" />
<el-button v-else size="small" type="primary" @click="addUltSkill">+ 添加大招</el-button>
</div>
<!-- 触发技能 -->
<div class="panel">
<h3>触发技能(lv4 激活)</h3>
<el-tabs>
<el-tab-pane label="atking 攻击后"><TriggerGroupEditor group="atking" :model="current.atking" :skill-options="allSkills" :skill-map="skillMap" :buff-options="buffOptions" /></el-tab-pane>
<el-tab-pane label="atked 受击后"><TriggerGroupEditor group="atked" :model="current.atked" :skill-options="allSkills" :skill-map="skillMap" :buff-options="buffOptions" /></el-tab-pane>
<el-tab-pane label="fstart 战斗开始"><TriggerGroupEditor group="fstart" :model="current.fstart" :skill-options="allSkills" :skill-map="skillMap" :buff-options="buffOptions" /></el-tab-pane>
<el-tab-pane label="fend 回合结束"><TriggerGroupEditor group="fend" :model="current.fend" :skill-options="allSkills" :skill-map="skillMap" :buff-options="buffOptions" /></el-tab-pane>
<el-tab-pane label="dead 死亡后"><TriggerGroupEditor group="dead" :model="current.dead" :skill-options="allSkills" :skill-map="skillMap" :buff-options="buffOptions" /></el-tab-pane>
</el-tabs>
</div>
<!-- 光环 -->
<div class="panel">
<h3>驻场光环 field</h3>
<FieldEditor :model="current.field" :field-options="fieldOptions" />
</div>
<!-- 复活已纳入 dead 触发组,通过 dead 编辑器的 overrides 配置 revive_hp / revive_count -->
<div class="panel">
<h3>复活配置(dead 触发组内)</h3>
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 10px">
复活已纳入 dead 触发组:在 dead 中添加技能(如 6501),并在 overrides 中配置 revive_hp(回血比例%)和 revive_count(次数上限)。
</el-alert>
</div>
<!-- 额外加成 -->
<div class="panel">
<h3>额外属性加成(hp/ap/def 百分比,crt/frz/stun/para 百分点)</h3>
<BonusEditor :model="current.bonus" />
</div>
<!-- 描述 -->
<div class="panel">
<h3>英雄描述 info</h3>
<el-input v-model="current.info" type="textarea" :rows="3" />
</div>
</section>
<template #footer>
<el-button size="small" type="danger" plain :disabled="activeIdx < 0" @click="removeHero(activeIdx)">删除当前英雄</el-button>
<el-button size="small" @click="editVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 新建英雄向导 -->
<HeroWizard
v-model:visible="wizardVisible"
:heroes="heroes"
:skill-map="skillMap"
:trigger-presets="triggerPresets"
:field-presets="fieldPresets"
@confirm="onWizardCreate"
/>
</template>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from './api'
import {
HERO_ROLES, GROW_TYPES, H_TYPES,
ROLE_ATK_SKILL, ROLE_ULT_SKILL, SUPPORT_ULT,
ROLE_DEFAULTS, ULT_CD_MAP,
defaultSkill,
} from './constants'
import SkillEditor from './components/SkillEditor.vue'
import TriggerGroupEditor from './components/TriggerGroupEditor.vue'
import FieldEditor from './components/FieldEditor.vue'
import BonusEditor from './components/BonusEditor.vue'
import HeroWizard from './components/HeroWizard.vue'
import TemplateManager from './components/TemplateManager.vue'
const heroes = ref([])
// 预设库(跨职业复用资产,与职业默认解耦)
const triggerPresets = ref([])
const fieldPresets = ref([])
const activeIdx = ref(-1)
const meta = ref(null)
const loadingMeta = ref(false)
// 视图切换:heroes 英雄列表 / templates 模板管理
const view = ref('heroes')
// 新建英雄向导可见性
const wizardVisible = ref(false)
// 编辑弹窗可见性:首页卡片点「修改/新建」打开,关闭仅收起不丢数据
const editVisible = ref(false)
const current = computed(() => heroes.value[activeIdx.value])
// 首页列表排序:none 原始顺序 / role 按职业枚举序(坦克→辅助)/ rarity 按星级
const sortKey = ref('none')
const sortOrder = ref('desc')
// 切换排序维度时重置方向:职业升序直观(坦克在前),星级降序直观(高星在前)
watch(sortKey, (k) => { sortOrder.value = k === 'role' ? 'asc' : 'desc' })
// 排序视图:携带原始下标,保证点击卡片仍能定位到 heroes 中的编辑对象,且不改写保存顺序
const sortedHeroes = computed(() => {
const list = heroes.value.map((h, i) => ({ h, i }))
if (sortKey.value === 'none') return list
const dir = sortOrder.value === 'asc' ? 1 : -1
const roleIdx = (key) => HERO_ROLES.findIndex((r) => r.key === key)
list.sort((a, b) => {
const d = sortKey.value === 'role'
? roleIdx(a.h.roleKey) - roleIdx(b.h.roleKey)
: (a.h.rarity || 0) - (b.h.rarity || 0)
// 同维度内按 uuid 升序兜底,保证排序稳定
return d !== 0 ? d * dir : a.h.uuid - b.h.uuid
})
return list
})
const skillMap = computed(() => meta.value?.SkillSet || {})
const allSkills = computed(() => Object.values(skillMap.value).sort((a, b) => a.uuid - b.uuid))
const fieldOptions = computed(() => Object.values(meta.value?.FieldSkillSet || {}).sort((a, b) => a.uuid - b.uuid))
// buff 候选:timed_buff_id 覆写下拉用(BuffList 按 id 升序)
const buffOptions = computed(() => Object.values(meta.value?.BuffList || {}).sort((a, b) => a.id - b.id))
const roleBase = computed(() => {
if (!current.value || !meta.value?.HERO_ROLE_BASE) return null
const roleEnum = HERO_ROLES.find((r) => r.key === current.value.roleKey)?.value
return meta.value.HERO_ROLE_BASE[roleEnum]
})
const basicSkill = computed(() => current.value?.skills?.[0])
const ultSkill = computed(() => current.value?.skills?.[1])
const basicSkillOptions = computed(() => {
if (!current.value) return []
const allowed = ROLE_ATK_SKILL[current.value.roleKey] || []
return allSkills.value.filter((s) => allowed.includes(s.uuid))
})
const ultSkillOptions = computed(() => {
if (!current.value) return []
const allowed = [...(ROLE_ULT_SKILL[current.value.roleKey] || []), ...SUPPORT_ULT]
return allSkills.value.filter((s) => allowed.includes(s.uuid))
})
function roleLabel(key) { return HERO_ROLES.find((r) => r.key === key)?.label || key }
function typeLabel(key) { return H_TYPES.find((t) => t.key === key)?.label || key }
// 卡片技能摘要:优先引用处命名,其次回退 SkillSet 底座名
function skillLabel(sk) {
if (!sk) return '-'
return `${sk.uuid} ${sk.name || skillMap.value[sk.uuid]?.name || ''}`.trim()
}
// 覆写键 → 短标签映射,未列出的键直接用原始键名兜底,保证任何参数都能显示
const OV_LABELS = {
hit_count: 'hit',
timed_buff_id: 'buff',
buff_value: 'val',
buff_chance: 'chance',
timed_debuff_id: 'debuff',
debuff_value: 'dval',
debuff_chance: 'dchance',
shield_duration: '盾时',
summon_count: '数',
thorns_dmg: 'thorns',
thorns_rate: 'thorns',
}
// 覆写对象转简写(如 ap100、crt10、proc:6001×100%、bck_chance20)
function overrideParts(ov) {
if (!ov) return []
const parts = []
for (const [k, v] of Object.entries(ov)) {
// 跳过空值与编辑器内部标记键
if (v == null || k === 'TGroup' || k === 'desc') continue
if (k === 'proc_skill') {
const sub = overrideParts(v.overrides).join(',')
parts.push(`proc:${v.s_uuid}×${v.chance}%${sub ? `(${sub})` : ''}`)
continue
}
if (typeof v === 'object') {
parts.push(`${k}${JSON.stringify(v)}`)
continue
}
const label = OV_LABELS[k] ?? k
parts.push(k === 'thorns_rate' ? `${label}${v}%` : `${label}${v}`)
}
return parts
}
// 技能完整明细:lv1 + 各 lv_entry 档位参数
function skillDetailFull(sk) {
if (!sk) return ''
const parts = []
const lv1 = overrideParts(sk.overrides).join(' ')
if (lv1) parts.push(lv1)
for (const e of sk.lv_entries || []) {
const ep = overrideParts(e.overrides).join(' ')
parts.push(`lv${e.lv}:${ep || '-'}`)
}
return parts.join(' | ')
}
// lv1 基础三维:HERO_ROLE_BASE + 编辑器偏移(meta 未加载时返回 null 不展示)
function heroStats(h) {
const roleEnum = HERO_ROLES.find((r) => r.key === h.roleKey)?.value
const base = meta.value?.HERO_ROLE_BASE?.[roleEnum]
if (!base) return null
return {
hp: base.hp + (h.statBase?.hp || 0),
ap: base.ap + (h.statBase?.ap || 0),
def: base.def + (h.statBase?.def || 0),
}
}
// 触发明细行:atking: 6004 奋力一击 lv4 ap300 格式,仅输出已启用的组
function triggerSummary(h) {
const lines = []
for (const tk of ['atking', 'atked', 'fstart', 'fend', 'dead']) {
const grp = h[tk]
if (!grp) continue
for (const [sid, entries] of Object.entries(grp)) {
const minLv = Math.min(...entries.map((e) => e.lv))
const name = entries[0]?.name || skillMap.value[sid]?.name || ''
const e = entries[0]
const ov = e?.overrides || {}
const parts = [`t_num${e?.t_num}`]
// 攻/受击组附带概率与保底信息(缺省 100%/0 不展示)
if (tk === 'atking' || tk === 'atked') {
if (e?.t_chance != null && e.t_chance < 100) {
parts.push(`概率${e.t_chance}%`)
if (e.pity_add > 0) parts.push(`保底+${e.pity_add}%`)
}
}
parts.push(...overrideParts(ov))
lines.push(`${tk}: ${sid} ${name} lv${minLv} ${parts.join(' ')}`)
}
}
return lines
}
function hasTriggers(h) {
return ['atking', 'atked', 'fstart', 'fend', 'dead'].some((k) => h[k] && Object.keys(h[k]).length)
}
// 光环明细行:lv4 · 大圣神威 0.25
function fieldSummary(h) {
if (!h.field?.length) return []
const fieldMap = meta.value?.FieldSkillSet || {}
return h.field.map((f) => {
const auras = f.uuids.map((u) => `${u.name || fieldMap[u.uuid]?.name || u.uuid} ${u.value}`).join('、')
return `lv${f.lv} · ${auras}`
})
}
// 额外加成档位总数(bonus 数组长度)
function bonusCount(h) {
return h.bonus?.length || 0
}
async function loadMeta() {
loadingMeta.value = true
try {
meta.value = await api.meta()
} catch (e) {
ElMessage.error('加载配置数据失败: ' + (e?.response?.data?.error || e.message))
} finally {
loadingMeta.value = false
}
}
async function refreshMeta() {
try {
await api.refresh()
await loadMeta()
ElMessage.success('已刷新')
} catch (e) {
ElMessage.error('刷新失败')
}
}
function openEdit(i) {
activeIdx.value = i
editVisible.value = true
}
// 「+ 新建英雄」改为打开向导:选职业/技能底座/勾选模板预设,一键生成完整英雄
function addHero() {
wizardVisible.value = true
}
// 向导创建完成:追加到列表并打开编辑弹窗微调
function onWizardCreate(h) {
heroes.value.push(h)
openEdit(heroes.value.length - 1)
}
function removeHero(i) {
heroes.value.splice(i, 1)
if (activeIdx.value >= heroes.value.length) activeIdx.value = heroes.value.length - 1
// 删除后关闭弹窗回到首页
editVisible.value = false
}
function addBasicSkill() {
if (!current.value) return
const first = basicSkillOptions.value[0]
if (!first) return ElMessage.warning('当前职业无可用普攻底座')
const sk = defaultSkill(first.uuid)
// 普攻默认提供 lv2 强化档位
sk.lv_entries = [
{ lv: 2, s_lv: 2, overrides: { ap: 120 }, desc: '' },
]
current.value.skills[0] = sk
}
function addUltSkill() {
if (!current.value) return
const first = ultSkillOptions.value[0]
if (!first) return ElMessage.warning('当前职业无可用大招底座')
const sk = defaultSkill(first.uuid)
// 大招 CD 按职业默认档生成(UltCdSet 档位),提供 lv8(SkillLv.Lv2)/lv16(SkillLv.Lv3) 档位,不默认注入 summon_count
const ultCdConf = ULT_CD_MAP[ROLE_DEFAULTS[current.value.roleKey]?.ultCd] || ULT_CD_MAP.Normal
sk.cd = ultCdConf.cd
sk.cdExpr = ultCdConf.cdExpr
sk.overrides = {}
sk.lv_entries = [
{ lv: 8, s_lv: 2, overrides: {}, desc: '' },
{ lv: 16, s_lv: 3, overrides: {}, desc: '' },
]
current.value.skills[1] = sk
}
async function loadHeroes() {
try {
const r = await api.fetchHeroes()
if (Array.isArray(r.heroes) && r.heroes.length) {
// 兼容旧数据:ap_bonus/hp_bonus/def_bonus 已废弃,统一迁移为空 bonus;
// 旧英雄无 fend 触发组(后期新增),补空对象避免 TriggerGroupEditor 拿到 undefined
for (const h of r.heroes) {
if (!Array.isArray(h.bonus)) h.bonus = []
if (!h.fend) h.fend = {}
delete h.ap_bonus; delete h.hp_bonus; delete h.def_bonus
}
heroes.value = r.heroes
}
} catch (e) {
ElMessage.error('加载英雄数据失败: ' + (e?.response?.data?.error || e.message))
}
}
async function loadTemplates() {
try {
const r = await api.fetchTemplates()
triggerPresets.value = Array.isArray(r.triggerPresets) ? r.triggerPresets : []
fieldPresets.value = Array.isArray(r.fieldPresets) ? r.fieldPresets : []
} catch (e) {
ElMessage.error('加载预设库失败: ' + (e?.response?.data?.error || e.message))
}
}
async function doSave() {
try {
const r = await api.saveHeroes(heroes.value)
ElMessage.success(`已保存 ${r.count} 个英雄 → ${r.path}`)
} catch (e) {
ElMessage.error('保存失败: ' + (e?.response?.data?.error || e.message))
}
}
async function doApply() {
try {
await ElMessageBox.confirm(
`将用 ${heroes.value.length} 个英雄直接覆盖 heroSet.ts 的英雄配置区域(@hero-setup:heroes-begin/end 标记之间),怪物配置不受影响。`,
'写入确认',
{ confirmButtonText: '覆盖写入', cancelButtonText: '取消', type: 'warning' }
)
} catch { return }
try {
const r = await api.applyHeroes(heroes.value)
ElMessage.success(`已写入 ${r.count} 个英雄 → ${r.path}`)
} catch (e) {
ElMessage.error('写入失败: ' + (e?.response?.data?.error || e.message))
}
}
async function doExportCsv() {
try {
await ElMessageBox.confirm(
`将用 ${heroes.value.length} 个英雄覆盖写入 assets/script/game/common/config/英雄设计表.csv`,
'导出确认',
{ confirmButtonText: '覆盖写入', cancelButtonText: '取消', type: 'warning' }
)
} catch { return }
try {
const r = await api.exportCsv(heroes.value)
ElMessage.success(`已写入 ${r.count} 个英雄 → ${r.path}`)
} catch (e) {
ElMessage.error('导出失败: ' + (e?.response?.data?.error || e.message))
}
}
onMounted(() => {
loadMeta()
loadHeroes()
loadTemplates()
})
</script>