Files
pixelheros/put/web/src/components/OverrideEditor.vue
panFD f2a4926c49 feat(override-editor): add shield duration override support
1. add shield_duration translation and override config entry
2. add warning tip for shield duration setting with usage notes
3. control tip visibility based on whether shield_duration is configured
2026-09-14 17:15:36 +08:00

373 lines
18 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="override-editor">
<div v-for="(item, idx) in rows" :key="idx" class="override-row">
<el-select v-model="item.key" placeholder="字段" size="small" style="width: 220px" filterable @change="onKeyChange(item)">
<el-option v-for="k in availableKeys(idx)" :key="k.key" :label="k.label" :value="k.key" />
</el-select>
<!-- 字符串型覆写(icon):输入帧名,提供常用候选可筛选 -->
<el-select
v-if="isStringKey(item.key)"
v-model="item.value"
placeholder="图标帧名"
size="small"
style="width: 280px"
filterable
allow-create
default-first-option
>
<el-option v-for="opt in SKILL_ICON_OPTIONS" :key="opt" :label="opt" :value="opt" />
</el-select>
<!-- 枚举型覆写(buff_target/TGroup):下拉选枚举值 -->
<el-select
v-else-if="isEnumKey(item.key)"
v-model="item.value"
placeholder="枚举值"
size="small"
style="width: 220px"
>
<el-option v-for="opt in enumOptions(item.key)" :key="String(opt.value)" :label="opt.label" :value="opt.value" />
</el-select>
<!-- 对象型覆写(proc_skill):施法后概率触发额外技能(出手即判定,与命中/弹道数无关),结构化编辑 s_uuid/chance/overrides -->
<template v-else-if="isObjectKey(item.key)">
<div class="proc-skill-editor">
<el-select
v-model="item.value.s_uuid"
placeholder="被触发技能 uuid"
size="small"
style="width: 220px"
filterable
@change="onProcSkillChange(item)"
>
<el-option v-for="s in skillOptions" :key="s.uuid" :label="`${s.uuid} ${s.name}`" :value="s.uuid" />
</el-select>
<el-input-number
v-model="item.value.chance"
:min="0"
:max="100"
:controls="false"
size="small"
placeholder="概率%"
style="width: 90px"
/>
<div class="proc-skill-overrides">
<span class="proc-skill-label">被触发技能覆写:</span>
<OverrideEditor v-model="item.value.overrides" :skill-options="skillOptions" :buff-options="buffOptions" :base-skill-uuid="item.value.s_uuid" />
</div>
</div>
</template>
<!-- buff/debuff 型覆写(timed_buff_id/timed_debuff_id):BuffList 下拉,副文本展示 buff info;allow-create 兼容 meta 未加载时手输 id -->
<el-select
v-else-if="isBuffKey(item.key)"
v-model="item.value"
placeholder="选择 buff id"
size="small"
style="width: 280px"
filterable
allow-create
default-first-option
@change="onBuffValueChange(item)"
>
<el-option v-for="b in buffOptions" :key="b.id" :label="`${b.id} ${b.name}`" :value="b.id">
<span>{{ b.id }} {{ b.name }}</span>
<span class="muted" style="float: right">{{ b.info }}</span>
</el-option>
</el-select>
<el-input-number v-else v-model="item.value" size="small" :controls="false" style="width: 120px" />
<el-button type="danger" size="small" text @click="remove(idx)">删除</el-button>
</div>
<!-- 选中 proc_skill 后的触发语义提示:施法出手即判定,与命中/弹道数无关 -->
<el-alert
v-if="hasProcSkill"
type="warning"
:closable="false"
show-icon
class="hint-alert"
style="margin-bottom: 6px"
title="proc_skill 为施法后触发"
description="出手即判定概率,一次施法只判定一次(多弹道/多召唤技能不会按实体数重复判定),不再依赖命中;即使技能落空也会触发。"
/>
<!-- 选中 debuff 后的取值语义提示:依 BuffList 配置(tick/modifier/易伤/控制)说明 debuff_value 的正负与生效方式 -->
<el-alert
v-if="debuffHint"
type="warning"
:closable="false"
show-icon
class="hint-alert"
style="margin-bottom: 6px"
:title="debuffHint.title"
:description="debuffHint.desc"
/>
<!-- 选中护盾时长后的取值语义提示:仅护盾技能生效,0=永久易踩坑 -->
<el-alert
v-if="shieldDurationHint"
type="warning"
:closable="false"
show-icon
class="hint-alert"
style="margin-bottom: 6px"
title="shield_duration 仅护盾技能(kind=Shield,如 6301)生效"
description="取值为秒数:>0 持续对应秒数(底座 6301 默认 7 秒);0 表示永久护盾(不是立即消失);不配置则沿用底座值。护盾数值由 ap 覆写控制(施法者最大生命 ap%)。"
/>
<!-- 选中 buff(增益)后的取值语义提示:缺省挂施法者自身;tick 类正=回血/负=自伤,modifier 类正=增强 -->
<el-alert
v-if="buffHint"
type="success"
:closable="false"
show-icon
class="hint-alert"
style="margin-bottom: 6px"
:title="buffHint.title"
:description="buffHint.desc"
/>
<el-alert
v-if="showEnemyBuffWarn"
type="info"
:closable="false"
show-icon
class="hint-alert"
style="margin-bottom: 6px"
title="buff/debuff 配置提示"
description="buff 为增益语义:缺省挂施法者自身(如吸血/攻击后强化),可用 buff_target 改为全体友方;debuff 为对敌减益语义:缺省挂被命中者(目标不可覆写),对敌技能请用 timed_debuff_id。proc_skill 为施法后概率触发主动技能(出手即判定,与命中/弹道数无关),不是挂 buff 的方式。"
/>
<el-button size="small" @click="add">+ 添加覆盖</el-button>
</div>
</template>
<script setup>
import { ref, watch, computed } from 'vue'
import { OVERRIDE_KEYS, SKILL_ICON_OPTIONS } from '../constants'
const props = defineProps({
modelValue: { type: Object, default: () => ({}) },
// 全部技能候选(proc_skill.s_uuid 下拉用),由 App 经 SkillEditor 透传
skillOptions: { type: Array, default: () => [] },
// 全部 buff 候选(timed_buff_id 下拉用),由 App 从 meta.BuffList 计算后透传
buffOptions: { type: Array, default: () => [] },
// 宿主技能 uuid:用于判定对敌技能(TGroup=Enemy)注入 timed_buff_id 的断链警示
baseSkillUuid: { type: Number, default: null },
})
const emit = defineEmits(['update:modelValue'])
// 宿主技能是否走对敌路径:有效 TGroup = overrides.TGroup 覆写值 ?? 底座 TGroup(Enemy=3;meta 未加载时不判定)
const isEnemyHost = computed(() => {
if (props.baseSkillUuid == null) return false
const base = props.skillOptions.find((s) => s.uuid === props.baseSkillUuid)
const tgroupRow = rows.value.find((r) => r.key === 'TGroup')
const effective = tgroupRow?.value ?? base?.TGroup
return effective === 3 || effective === 'Enemy'
})
// 对敌技能且注入了 timed_buff_id → 运行时断链不生效,警示引导改用 proc_skill / 底座表
const showEnemyBuffWarn = computed(() => isEnemyHost.value && rows.value.some((r) => r.key === 'timed_buff_id'))
// 是否配置了 proc_skill(施法后概率触发主动技能):控制触发语义提示显隐
const hasProcSkill = computed(() => rows.value.some((r) => r.key === 'proc_skill'))
// 是否配置了 shield_duration:控制护盾时长取值语义提示显隐(0=永久易踩坑)
const shieldDurationHint = computed(() => rows.value.some((r) => r.key === 'shield_duration'))
// 易伤 buff id(与游戏侧 BuffSet.VULNERABLE_BUFF_ID 对应),取值语义特殊需单独提示
const VULNERABLE_ID = 7002
// 当前选中的 debuff 配置(timed_debuff_id 行)
const selectedDebuff = computed(() => {
const row = rows.value.find((r) => r.key === 'timed_debuff_id')
const id = typeof row?.value === 'number' ? row.value : Number(row?.value)
if (!id || isNaN(id)) return null
return props.buffOptions.find((b) => b.id === id) ?? null
})
// 依 BuffList 配置推导 debuff_value 取值语义提示(正负号 / 生效方式 / 叠层)
const debuffHint = computed(() => {
const buff = selectedDebuff.value
if (!buff) return null
const stackTxt = buff.max_stack === 0 ? '无限叠层' : buff.max_stack > 1 ? `可叠 ${buff.max_stack} 层` : '不可叠层'
// 控制类:无数值语义
if (buff.is_control) {
return { title: `「${buff.name}」为控制类 debuff`, desc: `无需配置 debuff_value(不生效)。${stackTxt},持续 ${buff.duration}s。` }
}
// 易伤:正值,被打时按层累加附加伤害,可被易伤抗性减免
if (buff.id === VULNERABLE_ID) {
return { title: `「${buff.name}」debuff_value 用正值`, desc: `含义=每层被击中时附加的固定伤害。取值 10 → 目标每次被打多掉 10 血,${stackTxt}时按层累加(N 层=N×value),可被目标易伤抗性减免。负值无效(被拦截为 0)。结算在暴击后、护盾前,不吃暴击。` }
}
// tick 类(DoT/HoT):负=伤害 正=治疗,每 interval 结算,可按 ap 缩放
if (buff.tick) {
const scaleTxt = buff.tick.scale_by_ap_pct > 0 ? `,并按施法者 ap 缩放(每100 ap +${buff.tick.scale_by_ap_pct}%)` : ',不随 ap 缩放'
return { title: `「${buff.name}」为周期伤害 debuff,debuff_value 用负值表伤害`, desc: `含义=每 ${buff.tick.interval}s 一次的基础伤害(负值=掉血,正值=回血)。取值 -10 → 每跳约 10 点伤害${scaleTxt}。${stackTxt},持续 ${buff.duration}s。` }
}
// modifier 类(属性修饰):正负表增减,直接改属性
if (buff.modifiers?.length) {
const mod = buff.modifiers[0]
return { title: `「${buff.name}」为属性修饰 debuff`, desc: `debuff_value 覆写修饰数值(负=削减,正=增强),作用于属性 ${mod.attr}(${mod.op === 1 ? '百分比' : '固定值'})。${stackTxt},持续 ${buff.duration}s。` }
}
return { title: `「${buff.name}」`, desc: `${stackTxt},持续 ${buff.duration}s。` }
})
// 当前选中的 buff 配置(timed_buff_id 行)
const selectedBuff = computed(() => {
const row = rows.value.find((r) => r.key === 'timed_buff_id')
const id = typeof row?.value === 'number' ? row.value : Number(row?.value)
if (!id || isNaN(id)) return null
return props.buffOptions.find((b) => b.id === id) ?? null
})
// buff(增益)取值语义提示:缺省挂施法者自身;tick 类正=回血/负=自伤,modifier 类正=增强
const buffHint = computed(() => {
const buff = selectedBuff.value
if (!buff) return null
const stackTxt = buff.max_stack === 0 ? '无限叠层' : buff.max_stack > 1 ? `可叠 ${buff.max_stack} 层` : '不可叠层'
const targetTxt = '缺省挂施法者自身,可用 buff_target 改全体友方'
if (buff.is_control) {
return { title: `「${buff.name}」为控制类`, desc: `无需配置 buff_value(不生效)。${targetTxt}。${stackTxt},持续 ${buff.duration}s。` }
}
if (buff.tick) {
const scaleTxt = buff.tick.scale_by_ap_pct > 0 ? `,并按自身 ap 缩放(每100 ap +${buff.tick.scale_by_ap_pct}%)` : ',不随 ap 缩放'
return { title: `「${buff.name}」为周期效果 buff`, desc: `buff_value 正值=每 ${buff.tick.interval}s 回血,负值=自伤(如自残出血)。取值 10 → 每跳约回 10 血${scaleTxt}。${targetTxt}。${stackTxt},持续 ${buff.duration}s。` }
}
if (buff.modifiers?.length) {
const mod = buff.modifiers[0]
return { title: `「${buff.name}」为属性修饰 buff`, desc: `buff_value 覆写修饰数值(正=增强,负=削弱),作用于属性 ${mod.attr}(${mod.op === 1 ? '百分比' : '固定值'})。${targetTxt}。${stackTxt},持续 ${buff.duration}s。` }
}
return { title: `「${buff.name}」`, desc: `${targetTxt}。${stackTxt},持续 ${buff.duration}s。` }
})
// 本地行数组,允许 key 为空
const rows = ref(Object.entries(props.modelValue || {}).map(([key, value]) => ({ key, value })))
// 该行是否满足输出条件(proc_skill 需有合法 s_uuid,其余仅需有 key)
function isRowOutput(it) {
if (!it.key) return false
if (it.key === 'proc_skill') return !!it.value?.s_uuid
return true
}
// 外部 modelValue 变化时同步(如切换英雄):
// 仅当外部值与"当前可输出的行"不一致时才覆盖;proc_skill 编辑中(s_uuid 未填)的行不输出但需保留,
// 否则会因 modelValue 缺少 proc_skill 而把用户正在编辑的行整个清掉(表现为"添加无效")
watch(() => props.modelValue, (v) => {
const next = Object.entries(v || {}).map(([key, value]) => ({ key, value }))
const outputRows = rows.value.filter(isRowOutput)
if (JSON.stringify(next) !== JSON.stringify(outputRows)) {
// 保留编辑中(key 已选但未达输出条件)的行,追加在外部同步结果之后
const editing = rows.value.filter((r) => r.key && !isRowOutput(r))
rows.value = [...next, ...editing]
}
}, { deep: true })
// 每次行变化都尝试同步(过滤空 key;proc_skill 需有合法 s_uuid 才输出)
watch(rows, (arr) => {
const obj = {}
for (const it of arr) {
if (!isRowOutput(it)) continue
obj[it.key] = it.value
}
emit('update:modelValue', obj)
}, { deep: true })
// 已选中的 key 不再出现在其他行的下拉中
function availableKeys(idx) {
const used = new Set(rows.value.map((r, i) => (i === idx ? null : r.key)).filter(Boolean))
return OVERRIDE_KEYS.filter((k) => !used.has(k.key))
}
// 该覆写字段是否为字符串型(icon 取帧名,非数值)
function isStringKey(key) {
return OVERRIDE_KEYS.find((k) => k.key === key)?.type === 'string'
}
// 该覆写字段是否为对象型(proc_skill:{s_uuid, chance, overrides})
function isObjectKey(key) {
return OVERRIDE_KEYS.find((k) => k.key === key)?.type === 'object'
}
// 该覆写字段是否为枚举型(buff_target/TGroup 等)
function isEnumKey(key) {
return OVERRIDE_KEYS.find((k) => k.key === key)?.type === 'enum'
}
// 枚举型覆写的候选值列表
function enumOptions(key) {
return OVERRIDE_KEYS.find((k) => k.key === key)?.options || []
}
// 该覆写字段是否为 buff 型(timed_buff_id:值为 BuffList 配置 id)
function isBuffKey(key) {
return OVERRIDE_KEYS.find((k) => k.key === key)?.type === 'buff'
}
// 选中 proc_skill 被触发技能后,在其内嵌 overrides 自动补 TGroup 行(已存在不覆盖):
// 值取该技能底座的 TGroup,让内嵌编辑器能立刻按有效目标群体做对敌断链警示等判定,也便于直改释放对象
function onProcSkillChange(item) {
const overrides = item.value?.overrides
if (!overrides) return
if (overrides.TGroup !== undefined) return
const base = props.skillOptions.find((s) => s.uuid === item.value.s_uuid)
const name = TGROUP_NAMES[base?.TGroup]
if (name) overrides.TGroup = name
}
// 底座 TGroup 数值 → 字符串名(编辑器 TGroup 下拉 value 为字符串,导出时还原 TGroup.X 枚举表达式)
const TGROUP_NAMES = ['Self', 'Ally', 'Team', 'Enemy', 'All']
// allow-create 手输的 buff id 是字符串,归一化为数字,保证导出 TS 配置类型正确
function onBuffValueChange(item) {
if (typeof item.value === 'string' && item.value.trim() !== '' && !isNaN(Number(item.value))) {
item.value = Number(item.value)
}
ensureBuffCompanionRows(item.value, item.key === 'timed_debuff_id')
}
// 挂载 timed_buff_id / timed_debuff_id 后自动补齐配套三键(已存在的不重复添加):
// 数值预填 BuffList 默认值(modifiers[0].value / tick.damage_or_heal / duration),避免导出 0 值覆盖 buff 语义
// isDebuff=true 时补 debuff_* 键,否则补 buff_* 键
function ensureBuffCompanionRows(buffId, isDebuff = false) {
const buff = typeof buffId === 'number' ? props.buffOptions.find((b) => b.id === buffId) : null
const prefix = isDebuff ? 'debuff' : 'buff'
const has = (key) => rows.value.some((r) => r.key === key)
if (!has(`${prefix}_value`)) {
rows.value.push({ key: `${prefix}_value`, value: buff?.modifiers?.[0]?.value ?? buff?.tick?.damage_or_heal ?? 0 })
}
if (!has(`${prefix}_chance`)) {
rows.value.push({ key: `${prefix}_chance`, value: 100 })
}
if (!has(`${prefix}_duration`)) {
rows.value.push({ key: `${prefix}_duration`, value: buff?.duration ?? 5 })
}
}
// 对象型覆写行选中时初始化默认结构,避免 v-model 绑定到 undefined 子字段
function onKeyChange(item) {
if (isObjectKey(item.key)) {
item.value = { s_uuid: undefined, chance: 100, overrides: {} }
} else if (isStringKey(item.key)) {
item.value = ''
} else if (isEnumKey(item.key)) {
// 枚举默认取首个候选值
item.value = enumOptions(item.key)[0]?.value
} else if (isBuffKey(item.key)) {
item.value = undefined // buff id 必须显式选择,0 不是合法 id
} else {
item.value = 0
}
}
function add() {
rows.value.push({ key: '', value: 0 })
}
function remove(idx) {
rows.value.splice(idx, 1)
}
</script>
<style scoped>
.override-row { display: flex; gap: 8px; margin-bottom: 6px; align-items: center; }
/* proc_skill 结构化编辑:占满整行,子字段横排,内嵌覆写编辑器缩进展示 */
.proc-skill-editor { display: flex; gap: 8px; align-items: flex-start; flex-wrap: wrap; flex: 1; }
.proc-skill-overrides { width: 100%; padding-left: 12px; border-left: 2px solid var(--el-border-color); }
.proc-skill-label { font-size: 12px; color: var(--el-text-color-secondary); }
/* debuff 取值提示:压缩内边距并用 12px 小字,与编辑器其他辅助文本一致 */
.hint-alert { padding: 4px 8px; }
.hint-alert :deep(.el-alert__title) { font-size: 12px; line-height: 1.4; }
.hint-alert :deep(.el-alert__description) { font-size: 12px; line-height: 1.4; margin-top: 2px; }
.hint-alert :deep(.el-alert__icon) { font-size: 14px; width: 14px; }
</style>