Files
pixelheros/assets/script/game/common/config/HeroSkillDesc.ts
panFD ae6c69e4f3 refactor(hero): 调整英雄描述展示为满级效果
修改英雄能力描述的数据源等级为5级,展示满级能力信息;移除技能名后的档位后缀,统一文案展示规则。
2026-08-12 08:38:57 +08:00

719 lines
34 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 HeroSkillDesc.ts
* @description 英雄技能/能力描述的【唯一文字表达标准层】
*
* 设计目标:
* heroSet / SkillSet 中的 info / infos 仅作"基础备注",不再承担面板展示。
* 所有功能性描述(触发技能、驻场光环、复活、等级额外属性加成)统一由本模块
* 依据结构化配置动态拼出,数值永远来自 mergeSkillParams 合并后的实时配置,
* 杜绝"改数值忘改文案"的漂移。
*
* 输出(双格式,供 UI 按需取用):
* - buildSkillDesc(source) → 纯文本Label 用),数值无颜色
* - buildSkillDescRich(source) → 富文本RichText 用BBCode技能名蓝色、数值绿色高亮
* 两者共用同一套结构化行构建逻辑,仅渲染阶段区分,保证纯/富文本内容严格一致。
*
* 通用性(纯配置驱动):
* 1. 触发框架按触发类型遍历SkillSet + overrides不动
* 2. 效果文案:查 SkillDescSet 结构化模板渲染
* 3. 目标/属性名由本模块统一语义化target/hit_count、ATTR_NAME
*
* 数据源(入参抽象 ISkillDescSource
* heroInfo静态 1 级配置)与 HeroAttrsComp运行时应用 evolve 后的当前等级配置)
* 字段结构一致天然都满足。HeroBoxComp 传 model 显示当前等级;卡池/商店传 heroInfo 显示 1 级。
*
* 依赖:
* - heroSet :SkillTriggerName、SkillTriggerType、TriggerGrouped/LvFieldEntry/LvReviveEntry/LvBonusEntry 及 resolver
* - SkillSet :SkillSet、mergeSkillParams、SkillOverrides、SkillKind、TGroup
* - SkillDescSet:结构化描述模板
* - FieldSkillSet驻场技能、BuffList计时 buff 默认值兜底、Attrs属性枚举
*/
import { SkillTriggerName, SkillTriggerType, TriggerGrouped, LvTriggerEntry, LvFieldEntry, LvReviveEntry, LvBonusEntry, FieldEntry, resolveTriggerByLv, resolveFieldByLv, resolveReviveByLv, getTriggerSkillName, HGrowType, calcGrowBonus } from "./heroSet";
import { FieldSkillSet, mergeSkillParams, SkillConfig, SkillKind, SkillOverrides, SkillSet, TGroup } from "./SkillSet";
import { FieldDescSet, FieldValueFmt, SkillDescSet } from "./SkillDescSet";
import { BuffList } from "./BuffSet";
import { Attrs } from "./HeroAttrs";
import { CardConfig } from "./CardSet";
/**
* 生成器入参窄接口:只约束实际读取的字段。
* heroInfo 与 HeroAttrsComp 的对应字段结构完全一致(均为分组 + lv 数组),天然都满足此接口。
*/
export interface ISkillDescSource {
/** 当前等级(用于 resolve 触发技能/光环/复活取档;静态 heroInfo 恒为 1 */
lv?: number;
call?: TriggerGrouped;
dead?: TriggerGrouped;
fstart?: TriggerGrouped;
fend?: TriggerGrouped;
atking?: TriggerGrouped;
atked?: TriggerGrouped;
field?: LvFieldEntry[];
revive?: LvReviveEntry[];
/** 额外攻击/生命加成(按 lv 累加,用于成长文案) */
ap_bonus?: LvBonusEntry[];
hp_bonus?: LvBonusEntry[];
/** 成长类型(决定每级 hp/ap 标准递增增量) */
grow_type?: HGrowType;
/** 基础攻击/生命(成长基数,线性公式 = 基础 × 系数 × (lv-1) */
ap?: number;
hp?: number;
/** 原始基础攻击/生命HeroAttrsComp 场景优先用作成长基数,避免运行时 hp/ap 重复计成长) */
base_ap?: number;
base_hp?: number;
}
/**
* 一条能力的结构化行:数值片段单独抽出,供富文本高亮与纯文本复用。
* 渲染结果 = prefix + value(可高亮) + suffix。
*/
interface ISkillLine {
/** 数值前的文本(含触发条件、技能名、目标、动词等) */
prefix: string;
/** 需要高亮的核心数值文本(如 "15%"、"4次"、"+30"),无数值时为 "" */
value: string;
/** 数值后的文本(如 "持续5秒" 之类尾部补充) */
suffix: string;
/** 尚未激活(档位等级高于当前等级):富文本整行灰色、不做高亮 */
locked?: boolean;
/** 同一技能未来档位的数值列表(全档位合并模式专用):渲染为 value 后灰色 "/v1/v2" 拼接 */
nextValues?: string[];
}
/** 需要遍历的触发技能类型列表(不含 Field 和 Revive这两者结构不同需单独处理 */
const TRIGGER_KEYS: SkillTriggerType[] = [
SkillTriggerType.Call,
SkillTriggerType.Dead,
SkillTriggerType.FStart,
SkillTriggerType.FEnd,
SkillTriggerType.Atking,
SkillTriggerType.Atked,
];
/** 属性标准中文名映射(统一称谓,供 evolve 额外加成文案使用),仅覆盖 ap_bonus/hp_bonus 用到的属性 */
const ATTR_NAME: Partial<Record<Attrs, string>> = {
[Attrs.ap]: "攻击力",
[Attrs.hp_max]: "最大生命",
};
/** 富文本数值高亮色(鲜亮绿色,深色面板上高亮醒目) */
const RICH_VALUE_COLOR = "#3CE66E";
/** 数值描边色(深绿,衬托亮绿填充,与技能名橙填充+深褐描边同一设计思路) */
const RICH_VALUE_OUTLINE = "#145A32";
/**
* 数值富文本片段:亮绿填充 + 深绿描边。
* 高亮数值明度高,深色描边增强边缘对比、提升深色面板上的锐度。
* @param text 数值文本(含单位,如 "15%"、"4次"
*/
function valueRich(text: string | number): string {
return `<outline color=${RICH_VALUE_OUTLINE} width=1><color=${RICH_VALUE_COLOR}>${text}</color></outline>`;
}
/** 富文本技能名高亮色(蜜桃橙,暖色系,与面板绿/蓝/红/黄/紫均不冲突) */
const RICH_NAME_COLOR = "#F8C471";
/** 技能名描边色(深褐,衬托蜜桃橙填充,深色面板上温馨醒目) */
const RICH_NAME_OUTLINE = "#6E2C00";
/**
* 技能名富文本片段:[技能名]整体蜜桃橙填充 + 深褐描边(含方括号)。
* 括号与技能名同色同描边,正文/数值不描边,突出技能名视觉层级。
* @param name 技能名(纯文本,不含括号)
* @returns <outline><color>[技能名]</color></outline> 富文本片段
*/
function skillNameRich(name: string): string {
return `<outline color=${RICH_NAME_OUTLINE} width=1><color=${RICH_NAME_COLOR}>[${name}]</color></outline>`;
}
/** 富文本未激活档位整行灰色(低饱和,深色面板上可读但明显弱化) */
const RICH_LOCKED_COLOR = "#95A5A6";
/** 技能名「」匹配模式(仅本模块行首/行中技能名片段使用) */
const SKILL_NAME_PATTERN = /「([^」]*)」/g;
/** "持续X秒"匹配模式(卡片描述后处理:把持续时间数值也高亮) */
const DURATION_PATTERN = /持续(\d+)秒/g;
/** 解析目标描述文本(与战斗 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));
// 治疗类命中低血量队友SCastSystem.pickHealTargetsByMostMissingHp
if (skill.kind === SkillKind.Heal) {
return n > 1 ? `${n}名低血量队友` : "低血量队友";
}
return `${n}名队友`;
}
default:
return "全体友方";
}
}
/**
* 占位符模板替换,并把"核心数值片段"用 \x01...\x02 标记,便于上层拆分高亮。
*
* Why: 模板中 {value}{unit} 是玩家最关心的数值。这里在渲染时将其单独包裹,
* 使富文本可对其单独上色,纯文本则剥离标记后原样拼接,保证两种输出内容一致。
*
* @returns 含 \x01hlValue\x02 标记的完整文本
*/
function renderTemplate(templ: string, vars: Record<string, string | number>, hlValue: string): string {
let out = templ;
for (const key of Object.keys(vars)) {
out = out.split(`{${key}}`).join(String(vars[key]));
}
// 用 \x01...\x02 包裹数值片段作为标记,便于上层拆分为 prefix/value/suffix
return out.split(hlValue).join(`\x01${hlValue}\x02`);
}
/**
* 由一行模板渲染结果构造结构化行(剥离 \x01\x02 标记,把数值片段拆到 value 字段)
* @param head 行首(触发条件:技能名
* @param rendered 已用 \x01...\x02 标记数值片段的效果文本
*/
function makeLine(head: string, rendered: string): ISkillLine {
const start = rendered.indexOf("\x01");
const end = rendered.indexOf("\x02");
if (start >= 0 && end > start) {
// 恰好含一个数值片段prefix=行首+数值前文本value=数值suffix=数值后文本
return {
prefix: `${head}${rendered.slice(0, start)}`,
value: rendered.slice(start + 1, end),
suffix: rendered.slice(end + 1),
};
}
// 无数值片段(未命中模板或兜底 info剥离残留标记整行作为 prefix不高亮
return { prefix: `${head}${rendered.replace(/[\x01\x02]/g, "")}`, value: "", suffix: "" };
}
/**
* 根据合并后的技能配置构造结构化效果行(查 SkillDescSet 模板渲染 + 数值高亮抽取)
*
* 计时模式默认值兜底:
* overrides 仅给 timed_buff_id 而未覆写 buff_value / buff_duration 时,
* 从 BuffList[timed_buff_id] 取默认值,保证 1 级基础档文案完整。
*/
function buildEffectLine(head: string, skill: SkillConfig): ISkillLine {
const desc = SkillDescSet[skill.uuid];
// 未入表技能回退基础备注 info无法抽出数值整行不高亮
if (!desc) return { prefix: `${head}${String(skill.info ?? "")}`, value: "", suffix: "" };
const buff = skill.timed_buff_id !== undefined ? BuffList[skill.timed_buff_id] : undefined;
const isTimed = buff !== undefined && !!desc.templTimed;
const templ = isTimed ? desc.templTimed! : desc.templPerm;
const valueField = isTimed ? (desc.timedField ?? desc.permField) : desc.permField;
// 计时模式默认值兜底buff_value 未覆写时用 BuffList 默认修饰值
let value = (skill[valueField as keyof SkillConfig] as number | undefined);
if (value === undefined && isTimed) {
value = buff!.modifiers?.[0]?.value ?? buff!.tick?.damage_or_heal ?? 0;
}
value = value ?? 0;
const dur = skill.buff_duration ?? buff?.duration ?? 0;
// 高亮片段 = 数值 + 单位(如 "15%"、"4次"、"+30"),是玩家视觉焦点
const hlValue = `${value}${desc.unit}`;
const rendered = renderTemplate(templ, {
target: buildTargetDesc(skill),
value: value,
unit: desc.unit,
verb: desc.verb,
dur: dur,
}, hlValue);
return makeLine(head, rendered);
}
/**
* 驻场光环field结构化行查 FieldDescSet 按 type 渲染value 实时取数值并高亮。
*
* Why: FieldSkillConfig.info 是手写文案,改 value 易漂移。这里以 FieldSkillType
* 为 key 查标准动词,数值按 fmt比例/整数/减免)从 value 实时渲染。
*
* @param showName 是否在行中包含「技能名」片段(英雄面板需要,装备商店不需要)
* @param nameOverride 技能名覆盖(英雄语境传入 heroSet 语境专属命名,缺省用 FieldSkillSet 通用名)
*/
function buildFieldLine(head: string, entry: FieldEntry, showName: boolean = true, nameOverride?: string): ISkillLine | null {
const fs = FieldSkillSet[entry.uuid];
if (!fs) return null;
const desc = FieldDescSet[fs.type];
const nameSeg = showName ? `${nameOverride ?? fs.name}` : "";
// 未入表类型回退基础备注 info整行不高亮
if (!desc) return { prefix: `${head}${nameSeg}${fs.info}`, value: "", suffix: "" };
// 数值按格式渲染为高亮片段(数值取自词条自身 value
let valueText: string;
switch (desc.fmt) {
case FieldValueFmt.Percent:
valueText = `+${Math.round(entry.value * 100)}%`;
break;
case FieldValueFmt.IntMinus:
valueText = `-${entry.value}`;
break;
case FieldValueFmt.Int:
default:
valueText = `+${entry.value}`;
break;
}
return { prefix: `${head}${nameSeg}${desc.verb}`, value: valueText, suffix: "" };
}
/**
* 复活revive结构化行复刻战斗公式高亮复活次数与回血百分比
* 复活次数 = r_num + floor((lv - 1) * upr);回血百分比 = SkillSet[s_uuid].ap
*
* @param showLocked true 时列出全部档位并标激活状态(未激活档位次数按解锁等级预估);
* false 时仅输出当前等级生效档
*/
function buildReviveLines(source: ISkillDescSource, showName: boolean = true, showLocked: boolean = false): ISkillLine[] {
const lv = Math.max(1, source.lv ?? 1);
const entries = source.revive;
if (!entries?.length) return [];
const tpl = SkillTriggerName[SkillTriggerType.Revive] ?? "复活";
const makeLine = (entry: { s_uuid: number; r_num: number; upr: number }, head: string, countLv: number, locked: boolean): ISkillLine | null => {
const base = SkillSet[entry.s_uuid];
if (!base) return null;
const maxCount = entry.r_num + Math.floor((countLv - 1) * entry.upr);
const hpPct = base.ap ?? 0;
const nameSeg = showName ? `${base.name}` : "";
// 高亮片段选回血百分比(数值中最关键的收益指标)
return {
prefix: `${head}${nameSeg}可复活${maxCount}次, 每次恢复`,
value: `${hpPct}%`,
suffix: "生命",
locked,
};
};
if (!showLocked) {
const revive = resolveReviveByLv(entries, lv);
if (!revive) return [];
const line = makeLine(revive, `${tpl}:`, lv, false);
return line ? [line] : [];
}
// 全档位合并模式:每个 s_uuid 只出一行,等级前缀置前("Lv2: 复活 ..."
const groups: Record<number, LvReviveEntry[]> = {};
for (const e of entries) {
(groups[e.s_uuid] ??= []).push(e);
}
const lines: ISkillLine[] = [];
for (const key in groups) {
const sorted = [...groups[Number(key)]].sort((a, b) => a.lv - b.lv);
let active: LvReviveEntry | undefined;
for (const e of sorted) { if (e.lv <= lv) active = e; }
if (active) {
const line = makeLine(active, `Lv${active.lv}: ${tpl} `, lv, false);
if (!line) continue;
// 未来档回血数值以灰色追加(同 s_uuid 的 base.ap 通常相同,仅不同时才拼接)
const nextVals: string[] = [];
for (const e of sorted) {
if (e.lv <= lv) continue;
const v = `${SkillSet[e.s_uuid]?.ap ?? 0}%`;
if (v !== line.value && !nextVals.includes(v)) nextVals.push(v);
}
if (nextVals.length) line.nextValues = nextVals;
lines.push(line);
} else {
// 完全未激活:预览最低档,次数按解锁等级预估,整行灰色
const first = sorted[0];
const line = makeLine(first, `Lv${first.lv}: ${tpl} `, first.lv, true);
if (line) lines.push(line);
}
}
return lines;
}
/** 额外属性加成ap/hp累计结构化行成长递增 + 一次性奖励,高亮累计数值 */
function buildEvolveBonusLines(source: ISkillDescSource): ISkillLine[] {
const lv = Math.max(1, source.lv ?? 1);
// 成长线性增量优先用原始基础值base_*HeroAttrsComp 场景),否则用配置 hp/ap
// 缺基础值时按 0 处理(如纯触发技能描述场景),避免运行时 hp/ap 重复计成长
const baseHp = source.base_hp ?? source.hp ?? 0;
const baseAp = source.base_ap ?? source.ap ?? 0;
const grow = calcGrowBonus({ hp: baseHp, ap: baseAp, grow_type: source.grow_type }, lv);
let apTotal = grow.ap;
let hpTotal = grow.hp;
if (source.ap_bonus) {
for (const e of source.ap_bonus) { if (e.lv <= lv) apTotal += e.value; }
}
if (source.hp_bonus) {
for (const e of source.hp_bonus) { if (e.lv <= lv) hpTotal += e.value; }
}
const lines: ISkillLine[] = [];
if (apTotal !== 0) lines.push({ prefix: "成长:", value: `+${apTotal}`, suffix: ATTR_NAME[Attrs.ap] ?? "攻击力" });
if (hpTotal !== 0) lines.push({ prefix: "成长:", value: `+${hpTotal}`, suffix: ATTR_NAME[Attrs.hp_max] ?? "最大生命" });
return lines;
}
/**
* 构建全部能力结构化行(触发技能 + 驻场光环 + 复活 + 额外加成)。
* 纯文本与富文本两个公开接口共用此结果,仅渲染阶段不同。
*
* @param showName 是否在行中包含「技能名」片段
* @param showLocked true 时为全档位合并模式:每个技能只出一行——已激活档正常渲染且
* 未来档数值灰色拼接在 value 后nextValues完全未激活的技能
* 预览最低档、整行 locked 灰色、Lv 前缀置前。false 时仅输出当前生效档
*/
function buildSkillLines(source: ISkillDescSource, showName: boolean = true, showLocked: boolean = false): ISkillLine[] {
const lines: ISkillLine[] = [];
const lv = Math.max(1, source.lv ?? 1);
// ---- 6 种标准触发技能 ----
for (const key of TRIGGER_KEYS) {
const grouped = source[key] as TriggerGrouped | undefined;
if (!grouped) continue;
// 触发类型简称(召唤/亡语/起手/生息/进击/受击)
const name = SkillTriggerName[key] ?? key;
// 仅进击/受击带触发次数,拼为"×n";其余简称直接用
const needCount = key === SkillTriggerType.Atking || key === SkillTriggerType.Atked;
if (!showLocked) {
// 仅当前等级生效档resolve 取各 s_uuid ≤lv 的最大档)
for (const item of resolveTriggerByLv(grouped, lv)) {
const base = SkillSet[item.s_uuid];
if (!base) continue;
const skill = mergeSkillParams(base, item.overrides);
const trigger = needCount ? `${name}×${item.t_num}` : name;
// 触发语境专属名称(如 atked.6301=不屈壁垒),未配置回退通用名
const nameSeg = showName ? `${getTriggerSkillName(key, item.s_uuid)}` : "";
lines.push(buildEffectLine(`${trigger}:${nameSeg}`, skill));
}
continue;
}
// 全档位合并模式:每个 s_uuid 只出一行,等级前缀置前("Lv1: 进击×5 ..."
for (const sKey in grouped) {
const entries = grouped[sKey];
if (!entries?.length) continue;
const base = SkillSet[Number(sKey)];
if (!base) continue;
const sorted = [...entries].sort((a, b) => a.lv - b.lv);
const nameSeg = showName ? `${getTriggerSkillName(key, Number(sKey))}` : "";
const headOf = (e: LvTriggerEntry) => `Lv${e.lv}: ${needCount ? `${name}×${e.t_num}` : name} ${nameSeg}`;
// 取当前等级生效档≤lv 的最大档)
let active: LvTriggerEntry | undefined;
for (const e of sorted) { if (e.lv <= lv) active = e; }
if (active) {
const line = buildEffectLine(headOf(active), mergeSkillParams(base, active.overrides));
// 未来档数值以灰色 "/v1/v2" 追加在高亮数值后(仅收集与当前不同的数值,避免重复)
const nextVals: string[] = [];
for (const e of sorted) {
if (e.lv <= lv) continue;
const v = buildEffectLine("", mergeSkillParams(base, e.overrides)).value;
if (v && v !== line.value && !nextVals.includes(v)) nextVals.push(v);
}
if (nextVals.length) line.nextValues = nextVals;
lines.push(line);
} else {
// 技能完全未激活:预览最低档效果,整行灰色
const first = sorted[0];
const line = buildEffectLine(headOf(first), mergeSkillParams(base, first.overrides));
line.locked = true;
lines.push(line);
}
}
}
// ---- 驻场光环field查 FieldDescSet 结构化渲染value 数值高亮 ----
const fieldEntries = source[SkillTriggerType.Field] as LvFieldEntry[] | undefined;
if (fieldEntries?.length) {
const tpl = SkillTriggerName[SkillTriggerType.Field] ?? "光环";
if (!showLocked) {
for (const entry of resolveFieldByLv(fieldEntries, lv)) {
const line = buildFieldLine(`${tpl}:`, entry, showName);
if (line) lines.push(line);
}
} else {
// 全档位合并模式生效档≤lv 最大档)正常显示,未来档整行灰色且 Lv 置前;
// 被替换的历史档不显示resolve 语义下升级后旧档光环失效)
const sorted = [...fieldEntries].sort((a, b) => a.lv - b.lv);
let activeTier: LvFieldEntry | undefined;
for (const tier of sorted) { if (tier.lv <= lv) activeTier = tier; }
if (activeTier) {
for (const entry of activeTier.uuids) {
const line = buildFieldLine(`Lv${activeTier.lv}: ${tpl} `, entry, showName);
if (line) lines.push(line);
}
}
for (const tier of sorted) {
if (tier.lv <= lv) continue;
for (const entry of tier.uuids) {
const line = buildFieldLine(`Lv${tier.lv}: ${tpl} `, entry, showName);
if (!line) continue;
line.locked = true;
lines.push(line);
}
}
}
}
// ---- 复活revive----
lines.push(...buildReviveLines(source, showName, showLocked));
// ---- 额外属性加成ap/hp按当前等级累计----
lines.push(...buildEvolveBonusLines(source));
return lines;
}
/**
* 纯文本描述Label 用):数值无颜色。
* @param source 触发技能数据源heroInfo 静态配置 或 HeroAttrsComp 运行时当前等级配置)
* @returns 多行纯文本,每行一条能力,用 \n 分隔
*/
export function buildSkillDesc(source: ISkillDescSource): string {
return buildSkillLines(source)
.map(l => `${l.prefix}${l.value}${l.suffix}`)
.join("\n");
}
/**
* 富文本描述RichText 用BBCode技能名用 <color=#2E86C1> 蓝色、数值用 <color=#27AE60> 亮绿色高亮。
* @param source 触发技能数据源(同 buildSkillDesc
* @param showName 是否包含「技能名」片段(默认 true
* @param showLocked true 时为全档位合并模式:每个技能只出一行,已激活档数值高亮、
* 未来档数值灰色 "/v1/v2" 追加;完全未激活的技能整行灰色且 Lv 前缀置前
* 英雄盒面板用false 时仅输出当前等级生效档
* @returns 多行富文本串,用 \n 分隔
*/
export function buildSkillDescRich(source: ISkillDescSource, showName: boolean = true, showLocked: boolean = false): string {
return buildSkillLines(source, showName, showLocked)
// 未激活行保持整行灰色,不再补充"持续X秒"数值高亮
.map(l => l.locked ? renderLineRich(l) : highlightDuration(renderLineRich(l)))
.join("\n");
}
/**
* 单个技能详情富文本IBox 弹窗用):展示 CD/持续时间 + 结构化效果描述。
* @param skillUuid SkillSet 技能 UUID
* @param overrides 可选覆写参数(如药水卡自定义档位)
* @param lv 技能等级(仅用于展示,不影响数值计算)
* @returns 多行富文本串:第一行 CD/持续时间,第二行效果描述
*/
export function buildSingleSkillRich(skillUuid: number, overrides?: SkillOverrides, lv: number = 1): string {
const base = SkillSet[skillUuid];
if (!base) return "未知技能";
const skill = mergeSkillParams(base, overrides);
// SkillConfig 未定义 cd 字段,冷却时间由外部运行时数据传入;计时模式展示持续时间
const dur = skill.buff_duration ?? (skill.timed_buff_id !== undefined ? BuffList[skill.timed_buff_id]?.duration : undefined) ?? 0;
const cdTag = dur > 0 ? valueRich(`持续:${dur}s`) : "";
const nameTag = `${skillNameRich(skill.name)} Lv.${lv}`;
// 复用结构化效果行head 为空避免重复触发类型前缀
const line = buildEffectLine("", skill);
const prefix = line.prefix.replace(SKILL_NAME_PATTERN, (m, g1) => skillNameRich(g1));
const effect = line.value
? `${prefix}${valueRich(line.value)}${line.suffix}`
: `${prefix}${line.suffix}`;
return cdTag ? `${nameTag} ${cdTag}\n${effect}` : `${nameTag}\n${effect}`;
}
/**
* 装备/驻场光环详情富文本:展示全部词条,数值绿色高亮。
* @param entries 驻场光环词条数组uuid 指向 FieldSkillSet 底座value 为生效数值)
* @param showName 是否包含「技能名」片段(英雄面板 true装备商店 false
* @returns 多行富文本串,每行一个词条
*/
export function buildEquipRich(entries: FieldEntry[], showName: boolean = false): string {
if (!entries?.length) return "无词条";
const lines: string[] = [];
for (const entry of entries) {
const line = buildFieldLine("", entry, showName);
if (!line) continue;
const prefix = line.prefix.replace(SKILL_NAME_PATTERN, (m, g1) => skillNameRich(g1));
const text = line.value
? `${prefix}${valueRich(line.value)}${line.suffix}`
: `${prefix}${line.suffix}`;
lines.push(text);
}
return lines.length > 0 ? lines.join("\n") : "无词条";
}
/**
* 英雄信息弹窗技能描述富文本HeroMoreCom 用)。
*
* 格式:
* 「技能名」 Lv.当前技能等级:当前档技能效果
*
* 仅展示当前生效档位,不含升级提示;升级前后对比由升级确认框单独提供。
* 数据源为运行时 HeroAttrsComplv 取当前英雄等级),逐触发类型逐技能渲染。
* 技能等级取 LvTriggerEntry.s_lv未标注时按档位序号1/2/3...)兜底。
*
* @param source 英雄运行时数据源(含 lv 与触发技能分组)
* @returns 多行富文本串,\n 分隔
*/
export function buildSkillDescMoreRich(source: ISkillDescSource): string {
const lv = Math.max(1, source.lv ?? 1);
const lines: string[] = [];
// 单条效果 → 富文本(技能名蓝色、数值绿色高亮、持续秒数高亮)
const effectRich = (line: ISkillLine): string => {
const prefix = line.prefix.replace(SKILL_NAME_PATTERN, (m, g1) => skillNameRich(g1));
const text = line.value
? `${prefix}${valueRich(line.value)}${line.suffix}`
: `${prefix}${line.suffix}`;
return highlightDuration(text);
};
for (const key of TRIGGER_KEYS) {
const grouped = source[key] as TriggerGrouped | undefined;
if (!grouped) continue;
const trigName = SkillTriggerName[key] ?? key;
const needCount = key === SkillTriggerType.Atking || key === SkillTriggerType.Atked;
for (const sKey in grouped) {
const entries = grouped[sKey];
if (!entries?.length) continue;
const sUuid = Number(sKey);
const base = SkillSet[sUuid];
if (!base) continue;
const sorted = [...entries].sort((a, b) => a.lv - b.lv);
// 技能名走触发语境专属命名(如 atked.6301=不屈壁垒)
const skillName = getTriggerSkillName(key, sUuid);
// 当前生效档≤lv 最大档),未激活则用最低档
let activeIdx = -1;
for (let i = 0; i < sorted.length; i++) {
if (sorted[i].lv <= lv) activeIdx = i;
}
const curIdx = activeIdx >= 0 ? activeIdx : 0;
const cur = sorted[curIdx];
const curSLv = cur.s_lv ?? (curIdx + 1);
// 触发次数直接写为富文本(数字绿色高亮),如 "受伤3次"
const curTrig = needCount
? `${trigName}${valueRich(cur.t_num)}`
: trigName;
// 技能名直接展示,不附加档位数字后缀(由 lv 字段控制生效档,玩家无需关注内部档号)
const curHead = `${skillName}」: ${curTrig}, `;
lines.push(effectRich(buildEffectLine(curHead, mergeSkillParams(base, cur.overrides))));
}
}
// 驻场光环field直接展示技能名不附加档位数字后缀
const fieldEntries = source[SkillTriggerType.Field] as LvFieldEntry[] | undefined;
if (fieldEntries?.length) {
for (const entry of resolveFieldByLv(fieldEntries, lv)) {
const baseName = getTriggerSkillName(SkillTriggerType.Field, entry.uuid);
const line = buildFieldLine("", entry, true, baseName);
if (line) lines.push(effectRich(line));
}
}
// 重生revive每回合拥有 N 次重生机会,死亡后以 X% 血量原地重生
const revive = resolveReviveByLv(source[SkillTriggerType.Revive] as LvReviveEntry[] | undefined, lv);
if (revive) {
const reviveName = getTriggerSkillName(SkillTriggerType.Revive, revive.s_uuid);
lines.push(`${skillNameRich(reviveName)}: 每回合拥有${valueRich(revive.r_num)}次重生机会, 死亡后以${valueRich(`${Math.round(revive.upr * 100)}%`)}血量原地重生`);
}
return lines.join("\n");
}
/**
* 把"持续X秒"中的 X 也包裹绿色高亮(卡片描述后处理)。
* Why: 计时模板 {dur} 不在 \x01\x02 高亮标记内renderLineRich 仅高亮主数值;
* 持续时间对药水/计时 buff 同样关键,这里补充高亮,保证视觉一致。
*/
function highlightDuration(text: string): string {
return text.replace(DURATION_PATTERN, `持续${valueRich("$1")}`);
}
/**
* 结构化行 → 富文本串(技能名蓝色、数值绿色)。
* buildEquipRich / buildCardDescRich / buildSkillDescRich 共用,保证渲染规则唯一。
* 未激活行locked整行灰色弱化、不做技能名/数值高亮。
*/
function renderLineRich(line: ISkillLine): string {
if (line.locked) return `<color=${RICH_LOCKED_COLOR}>${line.prefix}${line.value}${line.suffix}</color>`;
const prefix = line.prefix.replace(SKILL_NAME_PATTERN, (m, g1) => skillNameRich(g1));
// 未来档数值灰色追加在当前高亮数值后("15%/20%/30%"suffix 仍位于整段数值之后
const next = line.nextValues?.length ? `<color=${RICH_LOCKED_COLOR}>/${line.nextValues.join("/")}</color>` : "";
return line.value
? `${prefix}${valueRich(line.value)}${next}${line.suffix}`
: `${prefix}${next}${line.suffix}`;
}
/**
* 扫描合并后技能的附加概率字段crt/frz/stun/bck非零的拼为高亮后缀。
* Why: 模板不再硬编码"有概率击晕"等文案,由实际配置驱动,避免描述与数值漂移。
* @returns 形如 ",30%击晕,20%冰冻";全为 0 时返回 ""
*/
function buildChanceSuffix(skill: SkillConfig): string {
const items: string[] = [];
const push = (val: number | undefined, label: string) => {
if (val && val > 0) items.push(`附加${valueRich(val)}%${label}`);
};
push(skill.crt, "暴击");
push(skill.frz, "冰冻");
push(skill.stun, "击晕");
push(skill.bck, "击退");
return items.length > 0 ? `,${items.join(",")}` : "";
}
/**
* 商店卡片描述富文本SCardComp / ItemListComp 用BBCode
*
* Why: 卡片 info 为手写文案,与 SkillSet/BuffList 数值易漂移。
* 本函数从 CardConfig.skill + overrides 合并后的实时配置生成描述,
* 数值随档位(如高级药水 buff_value自动同步数值绿色高亮、技能名蓝色。
*
* 描述规则:
* - 药品/技能卡card.skill 有效)→ 查 SkillDescSet 模板渲染(计时模式取 BuffList 默认值兜底);
* 未入表技能回退 SkillSet.info / card.info整行不高亮。
* - 装备卡card.field 有效)→ 复用 buildEquipRich 逐词条渲染。
* - 触发节奏t_times/t_inv融入单行描述定时触发"每M秒召唤K个「技能名」攻击敌人xxx共N次"K=overrides.num+1即时触发仅显示效果。
*
* @param card 卡片配置SCardSet 技能卡 / ICardSet 药品卡 / EquipSet 装备卡)
* @returns 多行富文本串;无可描述内容时回退 card.info
*/
export function buildCardDescRich(card: CardConfig): string {
// 装备卡:逐词条渲染驻场光环
if (card.field && card.field.length > 0) {
return buildEquipRich(card.field);
}
// 药品/技能卡:合并覆写后按 SkillDescSet 模板渲染
const base = card.skill ? SkillSet[card.skill] : undefined;
if (base) {
const skill = mergeSkillParams(base, card.overrides);
const line = buildEffectLine("", skill);
// 未入表技能回退链SkillSet.info → card.info
if (!line.value && !SkillDescSet[skill.uuid]) {
return String(skill.info || card.info || "");
}
// 触发节奏融入描述:定时触发"每M秒释放一次「技能名」xxx共N次";即时触发直接描述
const tTimes = card.t_times ?? 1;
const tInv = card.t_inv ?? 0;
const isInstant = card.is_inst || tInv <= 0;
// 附加概率后缀(暴击/冰冻/击晕/击退),由实际配置驱动
const suffix = buildChanceSuffix(skill);
const rich = highlightDuration(renderLineRich(line)) + suffix;
if (isInstant) {
return rich;
}
const rhythm = `${valueRich(tInv)}秒召唤${valueRich((card.overrides?.num ?? 0) + 1)}${skillNameRich(skill.name)}攻击敌人`;
const times = `, 共${valueRich(tTimes)}`;
return `${rhythm}, ${rich}${times}`;
}
return card.info || "";
}