/** * @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, 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> = { [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 `${text}`; } /** 富文本技能名高亮色(蜜桃橙,暖色系,与面板绿/蓝/红/黄/紫均不冲突) */ const RICH_NAME_COLOR = "#F8C471"; /** 技能名描边色(深褐,衬托蜜桃橙填充,深色面板上温馨醒目) */ const RICH_NAME_OUTLINE = "#6E2C00"; /** * 技能名富文本片段:[技能名]整体蜜桃橙填充 + 深褐描边(含方括号)。 * 括号与技能名同色同描边,正文/数值不描边,突出技能名视觉层级。 * @param name 技能名(纯文本,不含括号) * @returns [技能名] 富文本片段 */ function skillNameRich(name: string): string { return `[${name}]`; } /** 富文本未激活档位整行灰色(低饱和,深色面板上可读但明显弱化) */ 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, 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, uuid: number, showName: boolean = true, nameOverride?: string): ISkillLine | null { const fs = FieldSkillSet[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: "" }; // 数值按格式渲染为高亮片段 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 * * @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 = {}; 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 uuid of resolveFieldByLv(fieldEntries, lv)) { const line = buildFieldLine(`${tpl}:`, uuid, 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 uuid of activeTier.uuids) { const line = buildFieldLine(`Lv${activeTier.lv}: ${tpl} `, uuid, showName); if (line) lines.push(line); } } for (const tier of sorted) { if (tier.lv <= lv) continue; for (const uuid of tier.uuids) { const line = buildFieldLine(`Lv${tier.lv}: ${tpl} `, uuid, 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):技能名用 蓝色、数值用 亮绿色高亮。 * @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 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, (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.当前技能等级:当前档技能效果 * * 仅展示当前生效档位,不含升级提示;升级前后对比由升级确认框单独提供。 * 数据源为运行时 HeroAttrsComp(lv 取当前英雄等级),逐触发类型逐技能渲染。 * 技能等级取 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; // 技能名 + 档位数字后缀(如 "不屈壁垒1"),与光环命名口径一致 const curHead = `「${skillName}${curSLv}」: ${curTrig}, `; lines.push(effectRich(buildEffectLine(curHead, mergeSkillParams(base, cur.overrides)))); } } // 驻场光环(field):技能名按档位带数字后缀(如 [攻击光环1]/[攻击光环2]),便于玩家识别成长 const fieldEntries = source[SkillTriggerType.Field] as LvFieldEntry[] | undefined; if (fieldEntries?.length) { for (const uuid of resolveFieldByLv(fieldEntries, lv)) { // uuid 百位编码档位:7002 基础(0)=1、7202 +(2)=2、7402 ++(4)=3 const tier = Math.floor((uuid % 1000) / 100) / 2 + 1; const baseName = getTriggerSkillName(SkillTriggerType.Field, uuid); const line = buildFieldLine("", uuid, true, `${baseName}${tier}`); 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 `${line.prefix}${line.value}${line.suffix}`; const prefix = line.prefix.replace(SKILL_NAME_PATTERN, (m, g1) => skillNameRich(g1)); // 未来档数值灰色追加在当前高亮数值后("15%/20%/30%"),suffix 仍位于整段数值之后 const next = line.nextValues?.length ? `/${line.nextValues.join("/")}` : ""; 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 || ""; }