/**
* @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 `${text}`;
}
/** 富文本技能名高亮色(蜜桃橙,暖色系,与面板绿/蓝/红/黄/紫均不冲突) */
const RICH_NAME_COLOR = "#F8C471";
/** 技能名描边色(深褐,衬托蜜桃橙填充,深色面板上温馨醒目) */
const RICH_NAME_OUTLINE = "#6E2C00";
/**
* 技能名富文本片段:[技能名]整体蜜桃橙填充 + 深褐描边(含方括号)。
* 括号与技能名同色同描边,正文/数值不描边,突出技能名视觉层级。
* @param name 技能名(纯文本,不含括号)
* @returns [技能名] 富文本片段
*/
function skillNameRich(name: string): string {
return `[${name}]`;
}
/** 技能名「」匹配模式(仅本模块行首/行中技能名片段使用) */
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, 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 || "";
}