Files
pixelheros/assets/script/game/common/config/CardSkillText.ts
panFD 10a6d18836 refactor(skill-text): 重构技能文案系统,拆分英雄/卡牌语境
1.  将原HeroSkillDesc拆分为CardSkillText(卡牌/道具通用)和HeroSkillText(英雄专属)
2.  移除废弃的HeroSkillDesc文件与相关引用
3.  统一技能文案生成逻辑,修复数值漂移问题
4.  调整英雄配置中技能解锁等级与分档规则
5.  优化技能弹窗UI与参数传递逻辑
2026-08-28 08:36:39 +08:00

342 lines
15 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 CardSkillText.ts
* @description 卡牌/道具语境技能描述文案层(从已弃用的 HeroSkillDesc.ts 迁出)
*
* 职责:不依赖英雄语境的卡牌/道具描述富文本生成,数值永远来自
* mergeSkillParams 合并后的实时配置,杜绝"改数值忘改文案"的漂移。
*
* 输出(均为富文本 BBCode,RichText 直接可用):
* - buildCardDescRich(card) —— 商店卡片描述(技能卡/药品卡/装备卡统一入口)
* - buildEquipRich(entries, showName) —— 装备/驻场光环词条详情
* - buildSingleSkillRich(uuid, ov, lv) —— 单技能详情(CD/持续 + 效果,IBox 弹窗用)
*
* 英雄语境描述请使用 HeroSkillText.ts(heroInfo 内联模板驱动)。
*
* 依赖:
* - SkillSet / FieldSkillSet(技能/光环静态配置与合并)
* - SkillDescSet / FieldDescSet(结构化描述模板)
* - BuffList(计时 buff 默认值兜底)
* - CardConfig(卡牌统一配置结构)
*/
import { CardConfig } from "./CardSet";
import { FieldSkillSet, mergeSkillParams, SkillConfig, SkillKind, SkillOverrides, SkillSet, TGroup } from "./SkillSet";
import { FieldDescSet, FieldValueFmt, SkillDescSet, SkillDescConfig } from "./SkillDescSet";
import { BuffList } from "./BuffSet";
import { FieldEntry } from "./heroSet";
/**
* 一条能力的结构化行:数值片段单独抽出,供富文本高亮与纯文本复用。
* 渲染结果 = prefix + value(可高亮) + suffix。
*/
interface ISkillLine {
/** 数值前的文本(含触发条件、技能名、目标、动词等) */
prefix: string;
/** 需要高亮的核心数值文本(如 "15%"、"4次"、"+30"),无数值时为 "" */
value: string;
/** 数值后的文本(如 ",持续5秒" 之类尾部补充) */
suffix: string;
}
/** 富文本数值高亮色(鲜亮绿色,深色面板上高亮醒目) */
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 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, descOverride?: SkillDescConfig): ISkillLine {
const desc = descOverride ?? 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, entry: FieldEntry, showName: boolean = true): ISkillLine | null {
const fs = FieldSkillSet[entry.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: "" };
// 数值按格式渲染为高亮片段(数值取自词条自身 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: "" };
}
/**
* 把"持续X秒"中的 X 也包裹绿色高亮(卡片描述后处理)。
* Why: 计时模板 {dur} 不在 \x01\x02 高亮标记内,结构化行仅高亮主数值;
* 持续时间对药水/计时 buff 同样关键,这里补充高亮,保证视觉一致。
*/
function highlightDuration(text: string): string {
return text.replace(DURATION_PATTERN, `持续${valueRich("$1")}秒`);
}
/**
* 结构化行 → 富文本串(技能名橙色、数值绿色)。
* buildEquipRich / buildCardDescRich 共用,保证渲染规则唯一。
*/
function renderLineRich(line: ISkillLine): string {
const prefix = line.prefix.replace(SKILL_NAME_PATTERN, (m, g1) => skillNameRich(g1));
return line.value
? `${prefix}${valueRich(line.value)}${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(`附加${valueRich(val)}%${label}`);
};
push(skill.crt, "暴击");
push(skill.frz, "冰冻");
push(skill.stun, "击晕");
push(skill.bck, "击退");
return items.length > 0 ? `,${items.join(",")}` : "";
}
/**
* 单个技能详情富文本(IBox 弹窗用):展示 CD/持续时间 + 结构化效果描述。
* @param skillUuid SkillSet 技能 UUID
* @param overrides 可选覆写参数(如药水卡自定义档位)
* @param lv 技能等级(仅用于展示,不影响数值计算)
* @returns 多行富文本串:第一行名称/等级/持续时间,第二行效果描述
*/
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") : "无词条";
}
/**
* 商店卡片描述富文本(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 || "";
}