Files
pixelheros/assets/script/game/common/config/HeroSkillDesc.ts
panFD 096cae527b refactor(heroBox,skillDesc): 优化英雄面板技能描述显示
1. 新增showName参数控制技能描述是否显示技能名
2. 英雄盒面板隐藏技能名仅保留效果描述
3. 新增英雄强度评分公式设计文档
2026-08-01 19:55:14 +08:00

480 lines
22 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、HeroEvolve
* - SkillSet :SkillSet、mergeSkillParams、SkillOverrides、SkillKind、TGroup
* - SkillDescSet:结构化描述模板
* - FieldSkillSet驻场技能、BuffList计时 buff 默认值兜底、Attrs属性枚举
*/
import { HeroEvolve, SkillTriggerName, SkillTriggerType } 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 的对应字段结构完全一致,天然都满足此接口。
*/
export interface ISkillDescSource {
/** 当前等级(用于计算 revive 实际复活次数、evolve 累计加成;静态 heroInfo 恒为 1 */
lv?: number;
call?: { s_uuid: number; t_num: number; overrides?: SkillOverrides }[];
dead?: { s_uuid: number; t_num: number; overrides?: SkillOverrides }[];
fstart?: { s_uuid: number; t_num: number; overrides?: SkillOverrides }[];
fend?: { s_uuid: number; t_num: number; overrides?: SkillOverrides }[];
atking?: { s_uuid: number; t_num: number; overrides?: SkillOverrides }[];
atked?: { s_uuid: number; t_num: number; overrides?: SkillOverrides }[];
field?: number[];
revive?: { s_uuid: number; r_num: number; upr: number };
/** 等级进化配置heroInfo 自带HeroAttrsComp 记录快照),用于累计 ap/hp 额外加成文案 */
evolve?: Record<number, HeroEvolve>;
}
/**
* 一条能力的结构化行:数值片段单独抽出,供富文本高亮与纯文本复用。
* 渲染结果 = prefix + value(可高亮) + suffix。
*/
interface ISkillLine {
/** 数值前的文本(含触发条件、技能名、目标、动词等) */
prefix: string;
/** 需要高亮的核心数值文本(如 "15%"、"4次"、"+30"),无数值时为 "" */
value: string;
/** 数值后的文本(如 "持续5秒" 之类尾部补充) */
suffix: 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 = "#27AE60";
/** 富文本技能名高亮色(蓝色,与数值绿色区分,标识技能名「」片段) */
const RICH_NAME_COLOR = "#2E86C1";
/** 技能名「」匹配模式(仅本模块行首/行中技能名片段使用) */
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 是否在行中包含「技能名」片段(英雄面板需要,装备商店不需要)
*/
function buildFieldLine(head: string, uuid: number, showName: boolean = true): ISkillLine | null {
const fs = FieldSkillSet[uuid];
if (!fs) return null;
const desc = FieldDescSet[fs.type];
const nameSeg = showName ? `${fs.name}` : "";
// 未入表类型回退基础备注 info整行不高亮
if (!desc) return { prefix: `${head}${nameSeg}${fs.info}`, value: "", suffix: "" };
// 数值按格式渲染为高亮片段
let valueText: string;
switch (desc.fmt) {
case FieldValueFmt.Percent:
valueText = `+${Math.round(fs.value * 100)}%`;
break;
case FieldValueFmt.IntMinus:
valueText = `-${fs.value}`;
break;
case FieldValueFmt.Int:
default:
valueText = `+${fs.value}`;
break;
}
return { prefix: `${head}${nameSeg}${desc.verb}`, value: valueText, suffix: "" };
}
/**
* 复活revive结构化行复刻战斗公式高亮复活次数与回血百分比
* 复活次数 = r_num + floor((lv - 1) * upr);回血百分比 = SkillSet[s_uuid].ap
*/
function buildReviveLine(source: ISkillDescSource, showName: boolean = true): ISkillLine | null {
const revive = source.revive;
if (!revive) return null;
const base = SkillSet[revive.s_uuid];
if (!base) return null;
const lv = Math.max(1, source.lv ?? 1);
const maxCount = revive.r_num + Math.floor((lv - 1) * revive.upr);
const hpPct = base.ap ?? 0;
const tpl = SkillTriggerName[SkillTriggerType.Revive] ?? "复活";
const nameSeg = showName ? `${base.name}` : "";
// 高亮片段选回血百分比(数值中最关键的收益指标)
return {
prefix: `${tpl}:${nameSeg}可复活${maxCount}次,每次恢复`,
value: `${hpPct}%`,
suffix: "生命",
};
}
/** evolve 额外属性加成ap/hp累计结构化行高亮累计数值 */
function buildEvolveBonusLines(source: ISkillDescSource): ISkillLine[] {
const evolve = source.evolve;
if (!evolve) return [];
const lv = Math.max(1, source.lv ?? 1);
let apTotal = 0;
let hpTotal = 0;
for (let elv = 2; elv <= lv; elv++) {
const evo = evolve[elv];
if (!evo) continue;
apTotal += evo.ap_bonus ?? 0;
hpTotal += evo.hp_bonus ?? 0;
}
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;
}
/**
* 构建全部能力结构化行(触发技能 + 驻场光环 + 复活 + evolve 加成)。
* 纯文本与富文本两个公开接口共用此结果,仅渲染阶段不同。
* @param showName 是否在行中包含「技能名」片段(英雄盒面板传 false 隐藏技能名)
*/
function buildSkillLines(source: ISkillDescSource, showName: boolean = true): ISkillLine[] {
const lines: ISkillLine[] = [];
// ---- 6 种标准触发技能 ----
for (const key of TRIGGER_KEYS) {
const arr = source[key] as { s_uuid: number; t_num: number; overrides?: SkillOverrides }[] | undefined;
if (!arr?.length) continue;
// 触发类型简称(召唤/亡语/起手/生息/进击/受击)
const name = SkillTriggerName[key] ?? key;
// 仅进击/受击带触发次数,拼为"×n";其余简称直接用
const needCount = key === SkillTriggerType.Atking || key === SkillTriggerType.Atked;
for (const item of arr) {
const base = SkillSet[item.s_uuid];
if (!base) continue;
const skill = mergeSkillParams(base, item.overrides);
const trigger = needCount ? `${name}×${item.t_num}` : name;
const nameSeg = showName ? `${base.name}` : "";
lines.push(buildEffectLine(`${trigger}:${nameSeg}`, skill));
}
}
// ---- 驻场光环field查 FieldDescSet 结构化渲染value 数值高亮 ----
const fieldUuids = source[SkillTriggerType.Field] as number[] | undefined;
if (fieldUuids?.length) {
const tpl = SkillTriggerName[SkillTriggerType.Field] ?? "光环";
for (const uuid of fieldUuids) {
const line = buildFieldLine(`${tpl}:`, uuid, showName);
if (line) lines.push(line);
}
}
// ---- 复活revive----
const reviveLine = buildReviveLine(source, showName);
if (reviveLine) lines.push(reviveLine);
// ---- evolve 额外属性加成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 是否包含「技能名」片段(英雄盒面板传 false 隐藏技能名,默认 true
* @returns 多行富文本串,技能名片段包裹 <color=#2E86C1>、数值片段包裹 <color=#27AE60>,用 \n 分隔
*/
export function buildSkillDescRich(source: ISkillDescSource, showName: boolean = true): string {
return buildSkillLines(source, showName)
.map(l => {
const prefix = l.prefix.replace(SKILL_NAME_PATTERN, `「<color=${RICH_NAME_COLOR}>$1</color>」`);
const text = l.value
? `${prefix}<color=${RICH_VALUE_COLOR}>${l.value}</color>${l.suffix}`
: `${prefix}${l.suffix}`;
// 持续时间数值同样高亮(计时 buff 的"持续X秒"
return highlightDuration(text);
})
.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 ? `<color=${RICH_VALUE_COLOR}>持续:${dur}s</color>` : "";
const nameTag = `「<color=${RICH_NAME_COLOR}>${skill.name}</color>」 Lv.${lv}`;
// 复用结构化效果行head 为空避免重复触发类型前缀
const line = buildEffectLine("", skill);
const prefix = line.prefix.replace(SKILL_NAME_PATTERN, `「<color=${RICH_NAME_COLOR}>$1</color>」`);
const effect = line.value
? `${prefix}<color=${RICH_VALUE_COLOR}>${line.value}</color>${line.suffix}`
: `${prefix}${line.suffix}`;
return cdTag ? `${nameTag} ${cdTag}\n${effect}` : `${nameTag}\n${effect}`;
}
/**
* 装备/驻场光环详情富文本:展示全部词条,数值绿色高亮。
* @param fieldUuids FieldSkillSet 驻场光环 UUID 数组
* @param showName 是否包含「技能名」片段(英雄面板 true装备商店 false
* @returns 多行富文本串,每行一个词条
*/
export function buildEquipRich(fieldUuids: number[], showName: boolean = false): string {
if (!fieldUuids?.length) return "无词条";
const lines: string[] = [];
for (const uuid of fieldUuids) {
const line = buildFieldLine("", uuid, showName);
if (!line) continue;
const prefix = line.prefix.replace(SKILL_NAME_PATTERN, `「<color=${RICH_NAME_COLOR}>$1</color>」`);
const text = line.value
? `${prefix}<color=${RICH_VALUE_COLOR}>${line.value}</color>${line.suffix}`
: `${prefix}${line.suffix}`;
lines.push(text);
}
return lines.length > 0 ? lines.join("\n") : "无词条";
}
/**
* 把"持续X秒"中的 X 也包裹绿色高亮(卡片描述后处理)。
* Why: 计时模板 {dur} 不在 \x01\x02 高亮标记内renderLineRich 仅高亮主数值;
* 持续时间对药水/计时 buff 同样关键,这里补充高亮,保证视觉一致。
*/
function highlightDuration(text: string): string {
return text.replace(DURATION_PATTERN, `持续<color=${RICH_VALUE_COLOR}>$1</color>秒`);
}
/**
* 结构化行 → 富文本串(技能名蓝色、数值绿色)。
* buildEquipRich / buildCardDescRich 共用,保证渲染规则唯一。
*/
function renderLineRich(line: ISkillLine): string {
const prefix = line.prefix.replace(SKILL_NAME_PATTERN, `「<color=${RICH_NAME_COLOR}>$1</color>」`);
return line.value
? `${prefix}<color=${RICH_VALUE_COLOR}>${line.value}</color>${line.suffix}`
: `${prefix}${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(`附加<color=${RICH_VALUE_COLOR}>${val}</color>%${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秒释放一次「技能名」xxx共N次";即时触发仅显示效果。
*
* @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 = `每<color=${RICH_VALUE_COLOR}>${tInv}</color>秒释放一次「<color=${RICH_NAME_COLOR}>${skill.name}</color>」`;
const times = `,共<color=${RICH_VALUE_COLOR}>${tTimes}</color>次`;
return `${rhythm}${rich}${times}`;
}
return card.info || "";
}