/** * @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 :SkillTriggerDesc、SkillTriggerType、HeroEvolve * - SkillSet :SkillSet、mergeSkillParams、SkillOverrides、SkillKind、TGroup * - SkillDescSet:结构化描述模板 * - FieldSkillSet(驻场技能)、BuffList(计时 buff 默认值兜底)、Attrs(属性枚举) */ import { HeroEvolve, SkillTriggerDesc, 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"; /** * 生成器入参窄接口:只约束实际读取的字段。 * 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; } /** * 一条能力的结构化行:数值片段单独抽出,供富文本高亮与纯文本复用。 * 渲染结果 = 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> = { [Attrs.ap]: "攻击力", [Attrs.hp_max]: "最大生命", }; /** 富文本数值高亮色(深绿色,与项目绿色系同源,深色面板上清晰可读) */ const RICH_VALUE_COLOR = "#1E8449"; /** 解析目标描述文本(与战斗 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 实时渲染。 */ function buildFieldLine(head: string, uuid: number): ISkillLine | null { const fs = FieldSkillSet[uuid]; if (!fs) return null; const desc = FieldDescSet[fs.type]; // 未入表类型回退基础备注 info,整行不高亮 if (!desc) return { prefix: `${head}${fs.name} ${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}${fs.name} ${desc.verb}`, value: valueText, suffix: "" }; } /** * 复活(revive)结构化行:复刻战斗公式,高亮复活次数与回血百分比 * 复活次数 = r_num + floor((lv - 1) * upr);回血百分比 = SkillSet[s_uuid].ap */ function buildReviveLine(source: ISkillDescSource): 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 = SkillTriggerDesc[SkillTriggerType.Revive] ?? "复活时"; // 高亮片段选回血百分比(数值中最关键的收益指标) return { prefix: `${tpl}:${base.name} 可复活${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 加成)。 * 纯文本与富文本两个公开接口共用此结果,仅渲染阶段不同。 */ function buildSkillLines(source: ISkillDescSource): 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 tpl = SkillTriggerDesc[key] ?? key; for (const item of arr) { const base = SkillSet[item.s_uuid]; if (!base) continue; const skill = mergeSkillParams(base, item.overrides); const trigger = tpl.replace("n", String(item.t_num)); lines.push(buildEffectLine(`${trigger}:${base.name} `, skill)); } } // ---- 驻场光环(field):查 FieldDescSet 结构化渲染,value 数值高亮 ---- const fieldUuids = source[SkillTriggerType.Field] as number[] | undefined; if (fieldUuids?.length) { const tpl = SkillTriggerDesc[SkillTriggerType.Field] ?? "场上存活"; for (const uuid of fieldUuids) { const line = buildFieldLine(`${tpl}:`, uuid); if (line) lines.push(line); } } // ---- 复活(revive)---- const reviveLine = buildReviveLine(source); 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):数值用 高亮。 * @param source 触发技能数据源(同 buildSkillDesc) * @returns 多行富文本串,数值片段包裹 ,用 \n 分隔 */ export function buildSkillDescRich(source: ISkillDescSource): string { return buildSkillLines(source) .map(l => l.value ? `${l.prefix}${l.value}${l.suffix}` : `${l.prefix}${l.suffix}`) .join("\n"); }