Files
pixelheros/assets/script/game/common/HeroSkillText.ts
pan fdcd81c083 docs(server/data,script): 更新技能触发组文档与文本渲染逻辑
1. 补充技能触发组desc占位符使用说明
2. 新增触发概率与保底叠加值的参数传递
3. 优化技能文本渲染,避免概率描述重复展示
2026-09-21 16:03:10 +08:00

776 lines
37 KiB
TypeScript
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.
/**
* @file HeroSkillText.ts
* @description 英雄技能文案生成器(heroInfo 内联描述模板驱动)
*
* 英雄语境描述文案层(原 HeroSkillDesc 已由本模块 + CardSkillText 替代并移除):
* - 描述模板直接配置在 heroInfo(HSkillInfo.desc / LvSkillEntry.desc / LvTriggerEntry.desc),
* "同一底座技能、不同英雄语境"的表述差异在英雄配置处表达;
* - 数值永远从 mergeSkillParams 合并后的实时配置取值(占位符渲染),
* 改 overrides 数值文案自动同步,杜绝"改数值忘改文案"漂移;
* - 英雄当前等级取自 SingletonModuleComp 的局外成长等级(smc.getHeroOuterLv)。
*
* 两个输出(均为富文本 BBCode,RichText 直接可用):
* 1. buildHeroSkillDesc(heroUuid) —— 当前局外等级下各技能最大生效档的完整说明
* 2. buildHeroSkillTierDesc(heroUuid, skillUuid) —— 指定技能各阶段说明:
* slv1 给完整能力 + 激活英雄等级;slv2+ 给相对上一级的提升 + 解锁英雄等级
*
* 触发组口径:每个触发槽位(atking/dead/fstart/atked/call/field)整组视为一个技能,
* 组内多个 s_uuid / 光环词条合并为一个名称(组内首个自定义名)与一套说明统一展示:
* - buildHeroSkillDesc 中每组合并为一行(CHeroComp / HeroMoreCom 等直接消费)
* - buildHeroGroupTierLines(heroUuid, slot) 供弹窗按整组分档渲染(SinfoComp)
*
* 占位符规范(模板作者可用,渲染时数值片段自动绿色高亮):
* {target} 目标语义(自身/敌方/全体友方/N名队友…,与 SCastSystem 目标选取一致)
* {dur} 计时 buff 持续秒数(buff_duration 优先,回退 BuffList 默认)
* {buff_value} 计时 buff 修饰数值(tick 类取绝对值展示,modifiers 类保留正负号)
* {字段名} 合并后技能配置数值(ap/para/frz/stun/crt/bck/hit_count/num/summon_count…)
*
* 依赖:heroSet(配置与 resolver)、SkillSet/BuffSet/SkillDescSet(底座)、smc(局外等级)。
* 卡牌/道具语境描述见 config/CardSkillText.ts。
*/
import {
HeroInfo, heroInfo, SkillTriggerType, SkillTriggerName, HSkillInfo, LvTriggerEntry, FieldEntry,
TriggerGrouped, resolveSkillOverrides, resolveTriggerByLv, resolveFieldByLv,
getGroupedTriggerName, getFieldSkillName, getTriggerGroupName, getFieldGroupName,
LvBonusAttrsEntry, BonusAttrKey,
} from "./config/heroSet";
import { SkillSet, mergeSkillParams, SkillConfig, SkillKind, TGroup, FieldSkillSet } from "./config/SkillSet";
import { BuffList } from "./config/BuffSet";
import { FieldDescSet, FieldValueFmt } from "./config/SkillDescSet";
import { FacSet } from "./config/GameSet";
import { smc } from "./SingletonModuleComp";
/* ==================== 富文本片段(与 CardSkillText 同配色,保证各面板视觉一致) ==================== */
/** 富文本数值高亮色(鲜亮绿色) */
const RICH_VALUE_COLOR = "#3CE66E";
/** 数值描边色(深绿,衬托亮绿填充) */
const RICH_VALUE_OUTLINE = "#145A32";
/** 技能名高亮色(蜜桃橙) */
const RICH_NAME_COLOR = "#F8C471";
/** 技能名描边色(深褐) */
const RICH_NAME_OUTLINE = "#6E2C00";
/** 数值富文本片段:亮绿填充 + 深绿描边 */
function valueRich(text: string | number): string {
return `<outline color=${RICH_VALUE_OUTLINE} width=1><color=${RICH_VALUE_COLOR}>${text}</color></outline>`;
}
/** 技能名富文本片段:「技能名」蜜桃橙填充 + 深褐描边 */
function skillNameRich(name: string): string {
return `<outline color=${RICH_NAME_OUTLINE} width=1><color=${RICH_NAME_COLOR}>「${name}」</color></outline>`;
}
/* ==================== 模板渲染基础 ==================== */
/** 需要遍历的触发技能类型(field 结构不同,单独处理;复活已纳入 dead) */
const TRIGGER_KEYS: SkillTriggerType[] = [
SkillTriggerType.Call,
SkillTriggerType.Dead,
SkillTriggerType.FStart,
SkillTriggerType.FEnd,
SkillTriggerType.Atking,
SkillTriggerType.Atked,
];
/** 占位符模式:{target}/{dur}/{buff_value}/{技能字段名} */
const PLACEHOLDER_PATTERN = /\{(\w+)\}/g;
/**
* 构建触发语境短语(整组/单技能行首共用)。
* atking/atked 为周期循环触发(运行时按次数取模判定),拼为"每攻击N次"/"每受击N次"
* 表达循环语义(N=1 时退化为"每次攻击"/"每次受击");其余类型直接用触发名(召唤/死亡后/回合开始)。
*/
function buildTriggerLabel(type: SkillTriggerType, tNum: number, chance?: number): string {
if (type === SkillTriggerType.Atking || type === SkillTriggerType.Atked) {
// 配置了 t_chance 且 < 100 时追加概率说明(保底叠加为隐藏机制,不在文案展示)
const chanceSeg = chance !== undefined && chance < 100 ? `(${valueRich(Math.round(chance))}%概率)` : "";
return tNum <= 1
? `每次${SkillTriggerName[type]}${chanceSeg}`
: `每${SkillTriggerName[type]}${valueRich(tNum)}次${chanceSeg}`;
}
return SkillTriggerName[type];
}
/**
* 解析目标描述文本(与战斗 SCastSystem 目标选取语义严格一致)
*/
function buildTargetDesc(skill: SkillConfig): string {
switch (skill.TGroup) {
case TGroup.Self:
return "自身";
case TGroup.Enemy:
return "敌方";
case TGroup.Ally:
case TGroup.All:
return "全体友方";
case TGroup.Team: {
const n = Math.max(1, Math.floor(skill.hit_count ?? 1));
// 治疗类命中低血量队友
if (skill.kind === SkillKind.Heal) {
return n > 1 ? `${n}名低血量队友` : "低血量队友";
}
return `${n}名队友`;
}
default:
return "全体友方";
}
}
/** 计时 buff 生效时长(overrides.buff_duration 优先,回退 BuffList 默认) */
function getBuffDuration(skill: SkillConfig): number {
if (skill.timed_buff_id === undefined) return 0;
return skill.buff_duration ?? BuffList[skill.timed_buff_id]?.duration ?? 0;
}
/** 计时 buff 修饰数值(tick 类伤害取绝对值展示;modifiers 类保留正负号表达增减) */
function getBuffValue(skill: SkillConfig): number {
if (skill.timed_buff_id === undefined) return skill.buff_value ?? 0;
if (skill.buff_value !== undefined) return skill.buff_value;
const buff = BuffList[skill.timed_buff_id];
if (!buff) return 0;
if (buff.modifiers?.length) return buff.modifiers[0].value;
if (buff.tick) return Math.abs(buff.tick.damage_or_heal);
return 0;
}
/**
* 渲染 heroInfo 内联描述模板:数值占位符替换为合并后配置实时值并绿色高亮。
* Why: 数值不写死在模板里,overrides 调整档位数值时文案自动同步。
* @param tmpl 描述模板(含占位符)
* @param skill mergeSkillParams 合并后的技能配置
* @param extra 条目级参数占位符(触发技能的 t_chance/pity_add 不在 SkillConfig,由调用方透传;优先于技能字段)
*/
function renderDesc(tmpl: string, skill: SkillConfig, extra?: Record<string, number>): string {
return tmpl.replace(PLACEHOLDER_PATTERN, (raw, key: string) => {
if (key === "target") return buildTargetDesc(skill);
if (key === "dur") return valueRich(getBuffDuration(skill));
if (key === "buff_value") return valueRich(getBuffValue(skill));
if (extra && key in extra) return valueRich(String(extra[key]));
const v = (skill as unknown as Record<string, unknown>)[key];
// 未定义数值字段按 0 渲染,避免面板出现 undefined(模板与配置不匹配的兜底)
return v === undefined ? valueRich(0) : valueRich(String(v));
});
}
/** 触发档位的条目级占位符上下文(普攻/大招档位无此层参数,返回 undefined 走技能字段) */
function tierExtras(t: ISkillTierInfo): Record<string, number> | undefined {
return t.tChance === undefined ? undefined : { t_chance: t.tChance, pity_add: t.pityAdd ?? 0 };
}
/** 无模板时兜底:回退底座技能的 info 备注 */
function fallbackDesc(skill: SkillConfig): string {
return String(skill.info ?? "");
}
/** 取英雄展示等级:英雄用局外成长等级(smc),怪物无成长直接用配置 lv */
function getHeroDisplayLv(heroUuid: number, configLv: number, fac: FacSet): number {
if (fac !== FacSet.HERO) return Math.max(1, configLv);
return Math.max(1, smc.getHeroOuterLv(heroUuid));
}
/* ==================== 输出一:当前等级最大生效档说明 ==================== */
/**
* 输出一:英雄当前局外等级下,全部技能最大生效档的完整说明(富文本)。
*
* 覆盖:普攻/大招(skills,resolve ≤lv 最大档)、5 类触发技能、驻场光环(field)、
* 死亡触发(dead,含复活)。触发槽位整组视为一个技能(组内多技能/词条合并为一行,
* 组名取组内首个自定义名),普攻/大招仍逐技能一行。每行 = 「技能名」:(或「技能名」+
* 触发语境)+ 模板渲染后的效果文本(数值高亮),不展示技能等级档号(当前档由英雄局外等级隐含决定)。
*
* Why 普攻默认隐藏:skills 首位为普攻(全英雄共有基础能力),展示场景通常只关心
* 特色技能(大招/触发/光环),普攻行信息冗余;需要完整展示时传 showNormal=true。
*
* @param heroUuid 英雄 uuid(HeroInfo key)
* @param showName 是否包含「技能名」片段(面板 true,紧凑卡片 false)
* @param showNormal 是否展示普攻(skills 首位技能),默认 false
* @returns 多行富文本串,\n 分隔;英雄不存在时返回 ""
*/
export function buildHeroSkillDesc(heroUuid: number, showName: boolean = true, showNormal: boolean = false): string {
const hero = HeroInfo[heroUuid];
if (!hero) return "";
const lv = getHeroDisplayLv(heroUuid, hero.lv, hero.fac);
const lines: string[] = [];
const nameSeg = (n: string) => (showName && n ? skillNameRich(n) : "");
// ---- 普攻/大招(skills):resolve 当前等级生效档,渲染该档模板 ----
const skillKeys = Object.keys(hero.skills);
for (let i = 0; i < skillKeys.length; i++) {
// 首位技能 = 普攻,默认跳过(基础能力不占描述篇幅)
if (i === 0 && !showNormal) continue;
const hSkill = hero.skills[skillKeys[i]];
if (!hSkill) continue;
const base = SkillSet[hSkill.uuid];
if (!base) continue;
const skill = mergeSkillParams(base, resolveSkillOverrides(hSkill, lv));
// 生效档(≤lv 最大档)模板:未配置 desc 的档位继承基础/上一档模板
let inheritDesc = hSkill.desc;
for (const e of hSkill.lv_entries ?? []) {
if (e.lv <= lv && e.desc) inheritDesc = e.desc;
}
const text = inheritDesc ? renderDesc(inheritDesc, skill) : fallbackDesc(skill);
// 行首格式「技能名」:(不展示技能等级,等级由英雄局外等级隐含表达)
const head = nameSeg(hSkill.name ?? base.name);
lines.push(head ? `${head}: ${text}` : text);
}
// ---- 5 类触发技能:每个触发槽位整组视为一个技能,组内多技能效果合并为一行 ----
for (const type of TRIGGER_KEYS) {
const grouped = hero[type] as TriggerGrouped | undefined;
if (!grouped) continue;
const items = resolveTriggerByLv(grouped, lv);
if (items.length === 0) continue;
const parts: string[] = [];
for (const item of items) {
const base = SkillSet[item.s_uuid];
if (!base) continue;
const skill = mergeSkillParams(base, item.overrides);
// 档位模板继承链:组内 ≤lv 档依次覆盖(后配置的生效),缺省回退第 1 档
const entries = grouped[item.s_uuid] ?? [];
let tmpl: string | undefined;
for (const e of entries) {
if (e.lv <= lv && e.desc) tmpl = e.desc;
}
tmpl = tmpl ?? entries[0]?.desc;
const text = tmpl
? renderDesc(tmpl, skill, { t_chance: item.t_chance ?? 100, pity_add: item.pity_add ?? 0 })
: fallbackDesc(skill);
// 触发语境:"每攻击N次"循环语义(buildTriggerLabel),逗号衔接效果文本
// 模板已显式使用 {t_chance} 时不再自动追加概率后缀,避免同一行重复展示
const trig = buildTriggerLabel(type, item.t_num, tmpl?.includes("{t_chance}") ? undefined : item.t_chance);
parts.push(`${trig}, ${text}`);
}
// dead 组内的复活效果(revive_hp / revive_count)并入同一行
if (type === SkillTriggerType.Dead) {
for (const item of items) {
const reviveHp = item.overrides?.revive_hp;
const reviveCount = item.overrides?.revive_count ?? 0;
if (reviveHp === undefined || reviveCount <= 0) continue;
const text = `每回合可复活${valueRich(reviveCount)}次, 死亡后以${valueRich(`${Math.round(reviveHp)}%`)}生命原地重生`;
parts.push(`${SkillTriggerName[SkillTriggerType.Dead]}, ${text}`);
}
}
if (parts.length === 0) continue;
// 组名取组内首个自定义名(getTriggerGroupName)
lines.push(`${nameSeg(getTriggerGroupName(hero, type))}${parts.join(";")}`);
}
// ---- 驻场光环(field):整组视为一个技能,组内多词条合并为一行 ----
const fieldParts: string[] = [];
for (const entry of resolveFieldByLv(hero.field, lv)) {
const fs = FieldSkillSet[entry.uuid];
const desc = fs ? FieldDescSet[fs.type] : undefined;
if (!fs || !desc) continue;
const val = desc.fmt === FieldValueFmt.Percent
? `+${Math.round(entry.value * 100)}%`
: desc.fmt === FieldValueFmt.IntMinus ? `-${entry.value}` : `+${entry.value}`;
fieldParts.push(`${desc.verb}${valueRich(val)}`);
}
if (fieldParts.length > 0) {
// 组名取组内首个自定义名(getFieldGroupName)
lines.push(`${nameSeg(getFieldGroupName(hero))}${fieldParts.join(",")}`);
}
return lines.join("\n");
}
/* ==================== 输出二:指定技能各阶段(分档)说明 ==================== */
/** 一个技能等级档位的统一结构(普攻 lv_entries 与触发技能 LvTriggerEntry 归一) */
interface ISkillTierInfo {
/** 技能等级(展示用,取 s_lv,缺省按档位序号) */
sLv: number;
/** 该档生效的英雄等级(激活/解锁提示) */
heroLv: number;
/** 合并底座后的完整技能配置 */
skill: SkillConfig;
/** 该档描述模板(继承链解析后) */
desc?: string;
/** 该档显式配置的描述模板(未走继承链):配置后提升档直接展示该模板,优先于自动 diff */
ownDesc?: string;
/** 触发技能档位的触发次数(普攻无) */
tNum: number;
/** 触发技能档位的触发概率百分点(普攻无;缺省 100 必触发) */
tChance?: number;
/** 触发技能档位的保底叠加百分点(普攻无;缺省 0) */
pityAdd?: number;
}
/** 相邻档 diff 参与展示的字段(排除动画/弹道/特效等玩家无感字段),t_num 为触发技能专有 */
const DIFF_KEYS: string[] = [
"ap", "hit_count", "num", "summon_count", "crt", "frz", "stun", "para",
"bck", "bck_chance", "buff_duration", "buff_value", "t_num", "timed_buff_id",
"t_chance", "pity_add",
];
/** diff 字段的称谓与单位("新增/漂移"描述共用) */
const DIFF_META: Record<string, { label: string; unit: string }> = {
ap: { label: "伤害", unit: "%" },
hit_count: { label: "命中", unit: "次" },
num: { label: "双发", unit: "次" },
summon_count: { label: "召唤数", unit: "" },
crt: { label: "暴击", unit: "%" },
frz: { label: "冰冻", unit: "%" },
stun: { label: "击晕", unit: "%" },
para: { label: "麻痹", unit: "%" },
bck: { label: "击退", unit: "" },
bck_chance: { label: "击退概率", unit: "%" },
buff_duration: { label: "持续", unit: "秒" },
buff_value: { label: "增益强度", unit: "" },
t_num: { label: "触发次数", unit: "次" },
t_chance: { label: "触发概率", unit: "%" },
pity_add: { label: "保底叠加", unit: "%" },
};
/** 读取档位某展示字段的实时值(t_num/t_chance/pity_add/buff_value 走专用取值) */
function getTierValue(tier: ISkillTierInfo, key: string): number {
if (key === "t_num") return tier.tNum;
if (key === "t_chance") return tier.tChance ?? 100;
if (key === "pity_add") return tier.pityAdd ?? 0;
if (key === "buff_value") return getBuffValue(tier.skill);
const v = (tier.skill as unknown as Record<string, unknown>)[key];
return typeof v === "number" ? v : 0;
}
/**
* 相邻两档 diff:数值漂移(差值)+ 新功能(上级为 0 本级新增)+ 计时 buff 增减。
* Why: slv2+ 只描述"多得到了什么",让玩家一眼看清每级提升。
*/
function buildTierDiff(prev: ISkillTierInfo, cur: ISkillTierInfo): string[] {
const parts: string[] = [];
for (const key of DIFF_KEYS) {
// 计时 buff 增减单独处理:展示 buff 名而非内部 id
if (key === "timed_buff_id") {
const pb = prev.skill.timed_buff_id ?? 0;
const cb = cur.skill.timed_buff_id ?? 0;
if (pb === cb) continue;
if (!pb && cb) parts.push(`新增${valueRich(BuffList[cb]?.name ?? "计时增益")}`);
else if (pb && !cb) parts.push(`移除${valueRich(BuffList[pb]?.name ?? "计时增益")}`);
else parts.push(`${valueRich(BuffList[pb]?.name ?? "计时增益")}→${valueRich(BuffList[cb]?.name ?? "计时增益")}`);
continue;
}
const meta = DIFF_META[key];
if (!meta) continue;
const pv = getTierValue(prev, key);
const cv = getTierValue(cur, key);
if (cv === pv) continue;
if (pv === 0 && cv > 0) {
parts.push(`新增${meta.label}${valueRich(`${cv}${meta.unit}`)}`);
} else {
const d = cv - pv;
parts.push(`${meta.label}${valueRich(`${d > 0 ? "+" : ""}${d}${meta.unit}`)}`);
}
}
return parts;
}
/** 收集普攻/大招(skills)的全部等级档位(slv 升序,模板走继承链) */
function collectSkillTiers(hSkill: HSkillInfo): ISkillTierInfo[] {
const base = SkillSet[hSkill.uuid];
if (!base) return [];
const entries = [...(hSkill.lv_entries ?? [])].sort((a, b) => (a.s_lv ?? 0) - (b.s_lv ?? 0));
const tiers: ISkillTierInfo[] = [];
// slv1 = 基础档(底座 + 基础 overrides),激活等级取 s_lv≤1 档的 lv(缺省 1)
const lv1 = entries.find(e => (e.s_lv ?? 0) <= 1);
tiers.push({
sLv: lv1?.s_lv ?? 1,
heroLv: lv1?.lv ?? 1,
skill: mergeSkillParams(base, hSkill.overrides),
desc: lv1?.desc ?? hSkill.desc,
ownDesc: lv1?.desc ?? hSkill.desc,
tNum: 0,
});
// slv2+ = lv_entries 各档(与基础 overrides 浅合并,档位字段优先)
for (const e of entries) {
if ((e.s_lv ?? 0) < 2) continue;
tiers.push({
sLv: e.s_lv ?? 0,
heroLv: e.lv,
skill: mergeSkillParams(mergeSkillParams(base, hSkill.overrides), e.overrides),
desc: e.desc,
ownDesc: e.desc,
tNum: 0,
});
}
// 模板继承链:未配置 desc 的档位沿用上一档模板(机制不变仅数值变化的档位免配)
let inherit: string | undefined;
for (const t of tiers) {
if (t.desc) inherit = t.desc;
else t.desc = inherit;
}
return tiers;
}
/** 驻场光环词条数值展示片段(与 buildHeroSkillDesc 口径一致:比例/整数/减免) */
function buildFieldValueRich(fieldUuid: number, value: number): string {
const fs = FieldSkillSet[fieldUuid];
const desc = fs ? FieldDescSet[fs.type] : undefined;
if (!desc) return valueRich(value);
const val = desc.fmt === FieldValueFmt.Percent
? `+${Math.round(value * 100)}%`
: desc.fmt === FieldValueFmt.IntMinus ? `-${value}` : `+${value}`;
return valueRich(val);
}
/**
* 收集驻场光环词条分档行(hero.field 各英雄等级档,无 s_lv 概念,按档位序号编号)。
* slv1 给完整效果(动词 + 数值);slv2+ 给数值提升描述。
* 档位配置了 desc 时直接采用(策划自定义表述优先)。
*/
function collectFieldTierLines(hero: heroInfo, fieldUuid: number): HeroSkillTierLine[] {
const matched = (hero.field ?? [])
.filter(e => e.uuids.some(u => u.uuid === fieldUuid))
.sort((a, b) => a.lv - b.lv);
if (matched.length === 0) return [];
const fs = FieldSkillSet[fieldUuid];
const descCfg = fs ? FieldDescSet[fs.type] : undefined;
const verb = descCfg?.verb ?? "";
const lines: HeroSkillTierLine[] = [];
let prevValue: number | undefined;
matched.forEach((e, i) => {
const entry = e.uuids.find(u => u.uuid === fieldUuid)!;
if (e.desc) {
// 配置描述优先(策划自定义表述)
lines.push({ sLv: i + 1, heroLv: e.lv, text: e.desc });
} else if (i === 0 || !descCfg) {
// 首档:完整效果(动词 + 数值高亮);未入表类型回退词条 info
lines.push({ sLv: i + 1, heroLv: e.lv, text: descCfg ? `${verb}${buildFieldValueRich(fieldUuid, entry.value)}` : (fs?.info ?? "") });
} else {
// 提升档:数值由旧值提升至新值
lines.push({
sLv: i + 1, heroLv: e.lv,
text: `${verb}${buildFieldValueRich(fieldUuid, prevValue!)}提升至${buildFieldValueRich(fieldUuid, entry.value)}`,
});
}
prevValue = entry.value;
});
return lines;
}
/**
* 收集复活技能分档行(已纳入 dead 触发组,从 dead[skillUuid] 提取 revive_hp / revive_count)。
* slv1 给完整效果(复活次数 + 重生血量);slv2+ 给复活次数/血量变化。
* 档位配置了 desc 时直接采用。
*/
function collectReviveTierLines(hero: heroInfo, sUuid: number): HeroSkillTierLine[] {
const entries = hero.dead?.[sUuid];
if (!entries?.length) return [];
// 筛选带复活参数的档位,按 lv 升序
const matched = entries
.filter(e => e.overrides?.revive_hp !== undefined && (e.overrides?.revive_count ?? 0) > 0)
.sort((a, b) => a.lv - b.lv);
if (matched.length === 0) return [];
const lines: HeroSkillTierLine[] = [];
let prevCount: number | undefined;
let prevHp: number | undefined;
matched.forEach((e, i) => {
const count = e.overrides!.revive_count!;
const hpPct = Math.round(e.overrides!.revive_hp!);
if (e.desc) {
lines.push({ sLv: i + 1, heroLv: e.lv, text: e.desc });
} else if (i === 0) {
lines.push({ sLv: 1, heroLv: e.lv, text: `每回合可复活${valueRich(count)}次, 死亡后以${valueRich(`${hpPct}%`)}生命原地重生` });
} else if (prevCount !== undefined && (count !== prevCount || hpPct !== prevHp)) {
const parts: string[] = [];
if (count !== prevCount) parts.push(`复活次数${valueRich(prevCount)}次提升至${valueRich(count)}次`);
if (hpPct !== prevHp) parts.push(`回血比例${valueRich(prevHp)}%提升至${valueRich(hpPct)}%`);
lines.push({ sLv: i + 1, heroLv: e.lv, text: parts.join(",") });
} else {
lines.push({ sLv: i + 1, heroLv: e.lv, text: "无变化" });
}
prevCount = count;
prevHp = hpPct;
});
return lines;
}
/** 单个技能等级档位的展示行(结构化,供弹窗逐档渲染激活状态与描述) */
export interface HeroSkillTierLine {
/** 技能等级(slv) */
sLv: number;
/** 该档解锁/激活所需英雄等级 */
heroLv: number;
/** 行文本:slv1=完整效果;slv2+=相对上一级提升(数值漂移/新增效果),富文本 */
text: string;
}
/**
* 收集指定触发槽位的触发技能档位与触发语境词;未命中(组不存在/无该 s_uuid/无底座)返回 null。
*/
function collectTriggerTiers(hero: heroInfo, type: SkillTriggerType, skillUuid: number): { tiers: ISkillTierInfo[]; label: string } | null {
const grouped = hero[type] as TriggerGrouped | undefined;
const entries = grouped?.[skillUuid];
if (!grouped || !entries?.length) return null;
const base = SkillSet[skillUuid];
if (!base) return null;
const sorted = [...entries].sort((a, b) => (a.s_lv ?? 0) - (b.s_lv ?? 0));
let inherit: string | undefined;
const tiers = sorted.map((e: LvTriggerEntry, i: number) => {
if (e.desc) inherit = e.desc;
return {
sLv: e.s_lv ?? (i + 1),
heroLv: e.lv,
skill: mergeSkillParams(base, e.overrides),
desc: e.desc ?? inherit,
ownDesc: e.desc,
tNum: e.t_num,
tChance: e.t_chance ?? 100,
pityAdd: e.pity_add ?? 0,
};
});
// slv1 模板已显式使用 {t_chance} 时不再自动追加概率后缀,避免同一行重复展示
const firstTmpl = tiers[0]?.desc;
const label = buildTriggerLabel(type, sorted[0].t_num, firstTmpl?.includes("{t_chance}") ? undefined : sorted[0].t_chance);
return { tiers, label };
}
/** 档位 → 展示行:slv1 完整能力 + 数值;slv2+ 显式配置 desc 时直接渲染该模板,否则相对上一级的提升 */
function buildTierLines(tiers: ISkillTierInfo[], triggerLabel: string): HeroSkillTierLine[] {
const lines: HeroSkillTierLine[] = [];
for (let i = 0; i < tiers.length; i++) {
const t = tiers[i];
if (i === 0) {
// 技能 1 级:完整能力 + 数值
const text = t.desc ? renderDesc(t.desc, t.skill, tierExtras(t)) : fallbackDesc(t.skill);
lines.push({ sLv: t.sLv, heroLv: t.heroLv, text: triggerLabel ? `${triggerLabel}, ${text}` : text });
} else if (t.ownDesc) {
// 提升档:该档显式配置了模板(如护盾/治疗等 diff 标签语义不达的技能),策划表述优先
lines.push({ sLv: t.sLv, heroLv: t.heroLv, text: renderDesc(t.ownDesc, t.skill, tierExtras(t)) });
} else {
// 提升档:未配置模板,给相对上一级的提升
const diff = buildTierDiff(tiers[i - 1], t);
lines.push({ sLv: t.sLv, heroLv: t.heroLv, text: diff.length > 0 ? diff.join(",") : "无变化" });
}
}
return lines;
}
/**
* 输出二(结构化):指定技能各等级档位说明,按技能等级升序。
*
* slv1 给完整能力 + 数值;slv2+ 给相对上一级的提升。每档附带解锁英雄等级,
* 调用方可自行对比英雄当前等级(smc.getHeroOuterLv)判定激活状态。
*
* 技能定位顺序(覆盖英雄图鉴全部 5 类技能形态):
* 1. hero.skills[skillUuid] —— 普攻/大招
* 2. 5 类触发技能组 —— call/dead/fstart/atking/atked
* 3. hero.field —— 驻场光环词条(FieldSkillSet uuid)
* 4. hero.dead 中的复活技能 —— 已纳入 dead 触发组(revive_hp / revive_count)
* 均未命中返回 []。
*
* Why: 同一底座技能可能被多个英雄以不同 overrides 引用,必须以英雄语境取档;
* 同一底座还可能同时是普攻与触发技能(如 5002 的 6004),传 slot 指定槽位
* 可避免自动定位命中错误分组(激活等级/档位随之错位)。
*
* @param heroUuid 英雄 uuid(HeroInfo key)
* @param skillUuid 技能 uuid(skills key / 触发组 s_uuid / FieldSkillSet uuid / 复活 SkillSet uuid)
* @param slot 技能所属槽位(可空):指定时仅在该槽位查找;缺省按上述顺序自动定位
* @param minLv 最低展示档(默认 1):传 2 时裁剪掉 sLv<2 的档,仅保留 lv2+ 档位
* (普攻强化档作为独立技能展示的场景,如 heroSet 普攻 lv2 档单独出图标)
* @returns 档位行数组;技能不属于该英雄时返回 []
*/
export function buildHeroSkillTierLines(heroUuid: number, skillUuid: number, slot?: SkillTriggerType, minLv: number = 1): HeroSkillTierLine[] {
const hero = HeroInfo[heroUuid];
if (!hero) return [];
// 槽位指定:光环结构特殊直接生成行;触发组走统一档位结构(复活已纳入 dead)
if (slot === SkillTriggerType.Field) return collectFieldTierLines(hero, skillUuid);
if (slot !== undefined) {
const hit = collectTriggerTiers(hero, slot, skillUuid);
return hit ? buildTierLines(hit.tiers, hit.label) : [];
}
// 自动定位:普攻/大招优先,其次 5 类触发组,最后光环/复活
let tiers: ISkillTierInfo[] = [];
let triggerLabel = "";
const hSkill = hero.skills?.[skillUuid];
if (hSkill) {
// 普攻/大招路径
tiers = collectSkillTiers(hSkill);
} else {
// 触发技能路径:在 5 类触发组中定位 s_uuid
for (const type of TRIGGER_KEYS) {
const hit = collectTriggerTiers(hero, type, skillUuid);
if (hit) {
tiers = hit.tiers;
triggerLabel = hit.label;
break;
}
}
}
// 光环路径(无 ISkillTierInfo 中间结构,直接生成 HeroSkillTierLine)
if (tiers.length === 0) {
const fieldLines = collectFieldTierLines(hero, skillUuid);
if (fieldLines.length > 0) return minLv > 1 ? fieldLines.filter(l => l.sLv >= minLv) : fieldLines;
// 复活已纳入 dead 触发组,通过 dead 路径处理
return [];
}
const lines = buildTierLines(tiers, triggerLabel);
return minLv > 1 ? lines.filter(l => l.sLv >= minLv) : lines;
}
/**
* 驻场光环整组分档行(hero.field 全部词条合并展示)。
* 每档一行:新词条给完整效果(动词 + 数值),已有词条给数值提升;
* 组内仅 1 个词条时与单词条口径一致。档位配置了 desc(LvFieldEntry.desc)时优先采用。
*/
function collectFieldGroupTierLines(hero: heroInfo): HeroSkillTierLine[] {
const tiers = [...(hero.field ?? [])].sort((a, b) => a.lv - b.lv);
if (tiers.length === 0) return [];
const lines: HeroSkillTierLine[] = [];
let prev: FieldEntry[] = [];
tiers.forEach((e, i) => {
const parts: string[] = [];
for (const entry of e.uuids) {
const fs = FieldSkillSet[entry.uuid];
const descCfg = fs ? FieldDescSet[fs.type] : undefined;
if (!fs || !descCfg) continue;
const before = prev.find(p => p.uuid === entry.uuid);
if (!before) {
// 新词条:完整效果(动词 + 数值高亮)
parts.push(`${descCfg.verb}${buildFieldValueRich(entry.uuid, entry.value)}`);
} else if (before.value !== entry.value) {
// 已有词条:数值由旧值提升至新值
parts.push(`${descCfg.verb}${buildFieldValueRich(entry.uuid, before.value)}提升至${buildFieldValueRich(entry.uuid, entry.value)}`);
}
}
lines.push({ sLv: i + 1, heroLv: e.lv, text: e.desc ?? parts.join("\n") });
prev = e.uuids;
});
return lines;
}
/** 多技能档位行按解锁英雄等级合并:同阈值档位文本换行拼接,sLv 取该阈值下最大档 */
function mergeTierLinesByHeroLv(groups: HeroSkillTierLine[][]): HeroSkillTierLine[] {
if (groups.length === 0) return [];
if (groups.length === 1) return groups[0];
const byLv = new Map<number, HeroSkillTierLine[]>();
for (const lines of groups) {
for (const line of lines) {
const bucket = byLv.get(line.heroLv);
if (bucket) bucket.push(line);
else byLv.set(line.heroLv, [line]);
}
}
return [...byLv.keys()].sort((a, b) => a - b).map(heroLv => {
const bucket = byLv.get(heroLv)!;
return {
sLv: Math.max(...bucket.map(l => l.sLv)),
heroLv,
text: bucket.map(l => l.text).join("\n"),
};
});
}
/**
* 输出二(触发组整体):指定触发槽位整组的各等级档位说明,按解锁英雄等级升序。
*
* 每个触发槽位(atking/dead/fstart/atked/call/field)整组视为一个技能:
* 组内各 s_uuid 分别生成分档行后按解锁英雄等级阈值合并(同阈值文本换行拼接),
* 说明内只保留组名与效果文本,不再显示组内子技能名;驻场光环按词条维度合并。
* 每档附带解锁英雄等级,调用方对比 smc.getHeroOuterLv 判定激活状态。
*
* @param heroUuid 英雄 uuid(HeroInfo key)
* @param slot 触发槽位(field 表示驻场光环整组)
* @returns 合并后的档位行数组;该槽位未配置返回 []
*/
export function buildHeroGroupTierLines(heroUuid: number, slot: SkillTriggerType): HeroSkillTierLine[] {
const hero = HeroInfo[heroUuid];
if (!hero) return [];
if (slot === SkillTriggerType.Field) return collectFieldGroupTierLines(hero);
const grouped = hero[slot] as TriggerGrouped | undefined;
if (!grouped) return [];
const lineGroups: HeroSkillTierLine[][] = [];
for (const sUuid of Object.keys(grouped).map(Number)) {
const hit = collectTriggerTiers(hero, slot, sUuid);
if (!hit) continue;
lineGroups.push(buildTierLines(hit.tiers, hit.label));
}
return mergeTierLinesByHeroLv(lineGroups);
}
/**
* 输出二:指定技能的各阶段说明(富文本整段)。
*
* 输出结构(按技能等级升序,通用支持任意档数):
* - slv1:完整能力 + 数值 +(英雄X级激活)
* - slv2+:相对上一级的提升(数值漂移/新增效果/计时 buff 增减)+(英雄Y级解锁)
*
* @param heroUuid 英雄 uuid(HeroInfo key)
* @param skillUuid 技能 uuid(skills key 或触发组 s_uuid)
* @param showName 是否包含「技能名」片段
* @returns 多行富文本串,\n 分隔;技能不属于该英雄时返回 ""
*/
export function buildHeroSkillTierDesc(heroUuid: number, skillUuid: number, showName: boolean = true): string {
const hero = HeroInfo[heroUuid];
if (!hero) return "";
const tiers = buildHeroSkillTierLines(heroUuid, skillUuid);
if (tiers.length === 0) return "";
// 技能名:大招取 HSkillInfo.name,触发技能取组名,缺省回退底座名
const hSkill = hero.skills?.[skillUuid];
const name = hSkill
? (hSkill.name ?? SkillSet[skillUuid]?.name ?? "")
: resolveTriggerSkillName(hero, skillUuid);
const nameSeg = showName && name ? `${skillNameRich(name)} ` : "";
return tiers
.map((t, i) => `${nameSeg}Lv.${valueRich(t.sLv)}(英雄${valueRich(t.heroLv)}级${i === 0 ? "激活" : "解锁"}): ${t.text}`)
.join("\n");
}
/** 在英雄技能配置中解析技能对外展示名(触发组名 / 光环词条名 / 复活名 / 底座名) */
function resolveTriggerSkillName(hero: heroInfo, skillUuid: number): string {
for (const type of TRIGGER_KEYS) {
const grouped = hero[type] as TriggerGrouped | undefined;
if (grouped?.[skillUuid]?.length) return getGroupedTriggerName(grouped, skillUuid);
}
// 驻场光环:词条名取英雄语境(FieldEntry.name 优先,回退 FieldSkillSet 底座名)
if (hero.field?.some(e => e.uuids.some(u => u.uuid === skillUuid))) {
return getFieldSkillName(hero, skillUuid);
}
// 复活技能:名称取英雄语境(dead 组名,回退底座名)
if (hero.dead?.[skillUuid]?.length) {
return getGroupedTriggerName(hero.dead, skillUuid);
}
return SkillSet[skillUuid]?.name ?? "";
}
/* ==================== bonus(额外属性加成)展示文案 ==================== */
/** bonus 属性键 → 中文展示名与单位(hp/ap/def 为百分比,概率类为固定百分点) */
const BONUS_ATTR_LABEL: Record<BonusAttrKey, { name: string; suffix: string }> = {
hp: { name: "生命", suffix: "%" },
ap: { name: "攻击", suffix: "%" },
def: { name: "防御", suffix: "%" },
crt: { name: "暴击率", suffix: "" },
frz: { name: "冰冻", suffix: "" },
stun: { name: "击晕", suffix: "" },
para: { name: "麻痹", suffix: "" },
};
/**
* 生成单个 bonus 档位的效果描述(富文本)。
* entry.info 显式配置时优先渲染(占位符 {lv} 替换为解锁等级);否则按 attrs 键自动生成。
* @param entry bonus 档位配置
* @returns 富文本效果串(不含名称与解锁等级,由调用方拼接)
*/
export function buildBonusEffectDesc(entry: LvBonusAttrsEntry): string {
if (entry.info) return entry.info.replace(/\{lv\}/g, `${entry.lv}`);
const parts: string[] = [];
for (const key of Object.keys(entry.attrs) as BonusAttrKey[]) {
const v = entry.attrs[key];
if (v === undefined || v === 0) continue;
const meta = BONUS_ATTR_LABEL[key];
const sign = v > 0 ? "+" : "";
parts.push(`${meta.name} ${valueRich(`${sign}${v}${meta.suffix}`)}`);
}
return parts.join(",") || "无效果";
}