Files
pixelheros/put/web/src/App.vue
pan 799b93817d refactor: 重构技能触发逻辑,将proc_skill从命中触发改为施法后触发
1.  调整proc_skill触发时机为施法出手时判定,一次施法仅判定一次,与命中/弹道数无关
2.  移除HeroAtkSystem中旧的proc_skill触发逻辑,新增SCastSystem的tryProcSkill方法
3.  更新技能配置注释、编辑器文案与文档说明,同步调整服务端技能描述显示
4.  全局设置Element Plus组件为small尺寸,优化编辑器UI布局
5.  清理冗余的proc_skill快照传递逻辑,移除unused的reviveOptions计算属性
2026-09-08 17:22:41 +08:00

497 lines
20 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-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">
<!-- 首页:英雄卡片列表 -->
<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 :model="current.atking" :skill-options="allSkills" :skill-map="skillMap" :buff-options="buffOptions" /></el-tab-pane>
<el-tab-pane label="atked 受击后"><TriggerGroupEditor :model="current.atked" :skill-options="allSkills" :skill-map="skillMap" :buff-options="buffOptions" /></el-tab-pane>
<el-tab-pane label="fstart 战斗开始"><TriggerGroupEditor :model="current.fstart" :skill-options="allSkills" :skill-map="skillMap" :buff-options="buffOptions" /></el-tab-pane>
<el-tab-pane label="dead 死亡后"><TriggerGroupEditor :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>
</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,
defaultHero, 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'
const heroes = ref([])
const activeIdx = ref(-1)
const meta = ref(null)
const loadingMeta = 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',
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', '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}`, ...overrideParts(ov)]
lines.push(`${tk}: ${sid} ${name} lv${minLv} ${parts.join(' ')}`)
}
}
return lines
}
function hasTriggers(h) {
return ['atking', 'atked', 'fstart', '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() {
const nh = defaultHero()
const used = new Set(heroes.value.map((h) => h.uuid))
let uuid = 5001
while (used.has(uuid)) uuid++
nh.uuid = uuid
heroes.value.push(nh)
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)
// 大招提供 lv8(SkillLv.Lv2)/lv16(SkillLv.Lv3) 档位,不默认注入 summon_count
sk.cd = 8
sk.cdExpr = null
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
for (const h of r.heroes) {
if (!Array.isArray(h.bonus)) h.bonus = []
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 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()
})
</script>