refactor(HeroBox): 重构技能描述系统,支持富文本高亮与缓存优化

1. 将技能描述拆分为纯文本/富文本双输出接口,数值部分使用金色高亮
2. 新增技能描述缓存,避免重复解析排版降低性能
3. 重构技能文案生成逻辑,统一结构化行处理流程,保证纯/富文本内容一致
4. 修复目标描述文本中的措辞问题
This commit is contained in:
panFD
2026-07-30 22:47:20 +08:00
parent 04f39217ac
commit 0457078cbb
3 changed files with 802 additions and 573 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -8,10 +8,15 @@
* 依据结构化配置动态拼出,数值永远来自 mergeSkillParams 合并后的实时配置,
* 杜绝"改数值忘改文案"的漂移。
*
* 输出(双格式,供 UI 按需取用):
* - buildSkillDesc(source) → 纯文本Label 用),数值无颜色
* - buildSkillDescRich(source) → 富文本RichText 用BBCode数值用 <color> 高亮
* 两者共用同一套结构化行构建逻辑,仅渲染阶段区分,保证纯/富文本内容严格一致。
*
* 通用性(纯配置驱动):
* 1. 触发框架按触发类型遍历SkillSet + overrides不动
* 2. 效果文案:查 SkillDescSet 结构化模板渲染
* 3. 目标/单位/属性名由本模块统一语义化target/hit_count、ATTR_NAME
* 3. 目标/属性名由本模块统一语义化target/hit_count、ATTR_NAME
*
* 数据源(入参抽象 ISkillDescSource
* heroInfo静态 1 级配置)与 HeroAttrsComp运行时应用 evolve 后的当前等级配置)
@@ -49,8 +54,19 @@ export interface ISkillDescSource {
}
/**
* 需要遍历的触发技能类型列表(不含 Field 和 Revive这两者结构不同需单独处理
* 一条能力的结构化行:数值片段单独抽出,供富文本高亮与纯文本复用。
* 渲染结果 = 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,
@@ -60,24 +76,16 @@ const TRIGGER_KEYS: SkillTriggerType[] = [
SkillTriggerType.Atked,
];
/**
* 属性标准中文名映射(文字表达标准层的统一称谓,供 evolve 额外加成文案使用)
* 仅覆盖 evolve.ap_bonus / hp_bonus 用到的属性;其余按需补充。
*/
/** 属性标准中文名映射(统一称谓,供 evolve 额外加成文案使用),仅覆盖 ap_bonus/hp_bonus 用到的属性 */
const ATTR_NAME: Partial<Record<Attrs, string>> = {
[Attrs.ap]: "攻击力",
[Attrs.hp_max]: "最大生命",
};
/**
* 解析目标描述文本(与战斗 SCastSystem 目标选取语义严格一致)
*
* Why: Team 下 hit_count 表示"从全队中选取 N 个目标"(随机/血量最低),
* 并非全体。全体友方仅在 Ally / All 或未限定数量的 Team 群体光环语义下使用。
*
* @param skill 合并 overrides 后的技能配置
* @returns 目标短语,如 "自身"/"随机2名队友"/"血量最低队友"/"全体友方"/"敌方"
*/
/** 富文本数值高亮色(项目色板:金色,与等级配色协调) */
const RICH_VALUE_COLOR = "#F1C40F";
/** 解析目标描述文本(与战斗 SCastSystem 目标选取语义严格一致) */
function buildTargetDesc(skill: SkillConfig): string {
switch (skill.TGroup) {
case TGroup.Self:
@@ -89,9 +97,9 @@ function buildTargetDesc(skill: SkillConfig): string {
return "全体友方";
case TGroup.Team: {
const n = Math.max(1, Math.floor(skill.hit_count ?? 1));
// 治疗类命中的是血量最低队友SCastSystem.pickHealTargetsByMostMissingHp
// 治疗类命中最缺血队友SCastSystem.pickHealTargetsByMostMissingHp
if (skill.kind === SkillKind.Heal) {
return n > 1 ? `${n}名血量最低队友` : "血量最低队友";
return n > 1 ? `${n}最缺血队友` : "最缺血队友";
}
return `随机${n}名队友`;
}
@@ -101,28 +109,53 @@ function buildTargetDesc(skill: SkillConfig): string {
}
/**
* 占位符模板替换
* 占位符模板替换,并把"核心数值片段"用 \x01...\x02 标记,便于上层拆分高亮。
*
* Why: 模板中 {value}{unit} 是玩家最关心的数值。这里在渲染时将其单独包裹,
* 使富文本可对其单独上色,纯文本则剥离标记后原样拼接,保证两种输出内容一致。
*
* @returns 含 \x01hlValue\x02 标记的完整文本
*/
function renderTemplate(templ: string, vars: Record<string, string | number>): string {
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]));
}
return out;
// 用 \x01...\x02 包裹数值片段作为标记,便于上层拆分为 prefix/value/suffix
return out.split(hlValue).join(`\x01${hlValue}\x02`);
}
/**
* 根据合并后的技能配置生成效果描述文本(查 SkillDescSet 结构化模板渲染
* 由一行模板渲染结果构造结构化行(剥离 \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] 取默认值value=modifiers[0].valuedur=duration
* 保证 1 级基础档文案也完整(如占卜师 1 级"攻击力+30持续5秒")。
* 从 BuffList[timed_buff_id] 取默认值,保证 1 级基础档文案完整。
*/
function buildEffectDesc(skill: SkillConfig): string {
function buildEffectLine(head: string, skill: SkillConfig): ISkillLine {
const desc = SkillDescSet[skill.uuid];
// 未入表技能回退基础备注 info保证未迁移技能不报错
if (!desc) return String(skill.info ?? "");
// 未入表技能回退基础备注 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;
@@ -135,45 +168,43 @@ function buildEffectDesc(skill: SkillConfig): string {
value = buff!.modifiers?.[0]?.value ?? buff!.tick?.damage_or_heal ?? 0;
}
value = value ?? 0;
// 持续时间overrides 覆写优先,否则用 BuffList 默认 duration
const dur = skill.buff_duration ?? buff?.duration ?? 0;
return renderTemplate(templ, {
// 高亮片段 = 数值 + 单位(如 "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);
}
/**
* 计算当前等级下 revive 的实际复活次数与回血百分比,生成完整复活文案
*
* 与战斗 HeroAtkSystem 语义一致:
* 复活次数 = r_num + floor((lv - 1) * upr)
* 回血百分比 = SkillSet[revive.s_uuid].ap
* 复活revive结构化行复刻战斗公式高亮复活次数与回血百分比
* 复活次数 = r_num + floor((lv - 1) * upr);回血百分比 = SkillSet[s_uuid].ap
*/
function buildReviveDesc(source: ISkillDescSource): string {
function buildReviveLine(source: ISkillDescSource): ISkillLine | null {
const revive = source.revive;
if (!revive) return "";
if (!revive) return null;
const base = SkillSet[revive.s_uuid];
if (!base) return "";
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 `${tpl}:${base.name} 可复活${maxCount}次,每次恢复${hpPct}%生命`;
// 高亮片段选回血百分比(数值中最关键的收益指标)
return {
prefix: `${tpl}:${base.name} 可复活${maxCount}次,每次恢复`,
value: `${hpPct}%`,
suffix: "生命",
};
}
/**
* 累计当前等级已生效的 evolve 额外属性加成ap_bonus / hp_bonus生成文案行
*
* Why: evolve 的属性加成不体现在触发技能配置里,若不平铺成文字,面板上该部分
* 能力会静默丢失。这里按 lv 从 2 级累计到当前等级,逐条输出。
*/
function buildEvolveBonusLines(source: ISkillDescSource): string[] {
/** evolve 额外属性加成ap/hp累计结构化行高亮累计数值 */
function buildEvolveBonusLines(source: ISkillDescSource): ISkillLine[] {
const evolve = source.evolve;
if (!evolve) return [];
const lv = Math.max(1, source.lv ?? 1);
@@ -185,28 +216,20 @@ function buildEvolveBonusLines(source: ISkillDescSource): string[] {
apTotal += evo.ap_bonus ?? 0;
hpTotal += evo.hp_bonus ?? 0;
}
const lines: string[] = [];
if (apTotal !== 0) lines.push(`成长:额外+${apTotal}${ATTR_NAME[Attrs.ap] ?? "攻击力"}`);
if (hpTotal !== 0) lines.push(`成长:额外+${hpTotal}${ATTR_NAME[Attrs.hp_max] ?? "最大生命"}`);
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;
}
/**
* 将英雄的触发技能/能力配置转换为标准可读描述文本
*
* 处理流程:
* 1. 遍历 6 种标准触发技能,查 SkillSet + mergeSkillParams + SkillDescSet 拼行。
* 2. 处理 field驻场光环读 FieldSkillSet 的 type+value 结构化配置。
* 3. 处理 revive复活体现复活次数与回血百分比。
* 4. 处理 evolve 额外属性加成ap_bonus/hp_bonus累计文案。
*
* @param source 触发技能数据源heroInfo 静态配置 或 HeroAttrsComp 运行时当前等级配置)
* @returns 多行描述文本,每行一条能力,用 \n 分隔
* 构建全部能力结构化行(触发技能 + 驻场光环 + 复活 + evolve 加成)。
* 纯文本与富文本两个公开接口共用此结果,仅渲染阶段不同。
*/
export function buildSkillDesc(source: ISkillDescSource): string {
const lines: string[] = [];
function buildSkillLines(source: ISkillDescSource): ISkillLine[] {
const lines: ISkillLine[] = [];
// ---- 第一步:6 种标准触发技能 ----
// ---- 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;
@@ -216,26 +239,50 @@ export function buildSkillDesc(source: ISkillDescSource): string {
if (!base) continue;
const skill = mergeSkillParams(base, item.overrides);
const trigger = tpl.replace("n", String(item.t_num));
lines.push(`${trigger}:${base.name} ${buildEffectDesc(skill)}`);
lines.push(buildEffectLine(`${trigger}:${base.name} `, skill));
}
}
// ---- 第二步:驻场光环field----
// ---- 驻场光环field:读 FieldSkillSet 结构化 info不抽数值 ----
const fieldUuids = source[SkillTriggerType.Field] as number[] | undefined;
if (fieldUuids?.length) {
const tpl = SkillTriggerDesc[SkillTriggerType.Field] ?? "场上存活";
for (const uuid of fieldUuids) {
const fs = FieldSkillSet[uuid];
if (fs) lines.push(`${tpl}:${fs.name} ${fs.info}`);
if (fs) lines.push({ prefix: `${tpl}:${fs.name} ${fs.info}`, value: "", suffix: "" });
}
}
// ---- 第三步:复活revive----
const reviveLine = buildReviveDesc(source);
// ---- 复活revive----
const reviveLine = buildReviveLine(source);
if (reviveLine) lines.push(reviveLine);
// ---- 第四步:evolve 额外属性加成ap/hp----
// ---- evolve 额外属性加成ap/hp----
lines.push(...buildEvolveBonusLines(source));
return lines.join("\n");
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> 高亮。
* @param source 触发技能数据源(同 buildSkillDesc
* @returns 多行富文本串,数值片段包裹 <color=#F1C40F>,用 \n 分隔
*/
export function buildSkillDescRich(source: ISkillDescSource): string {
return buildSkillLines(source)
.map(l => l.value
? `${l.prefix}<color=${RICH_VALUE_COLOR}>${l.value}</color>${l.suffix}`
: `${l.prefix}${l.suffix}`)
.join("\n");
}

View File

@@ -12,7 +12,7 @@
* - HeroInfoheroSet—— 英雄静态配置
*/
import { mLogger } from "../common/Logger";
import { _decorator, Node, Sprite, Label, resources, AnimationClip, SpriteFrame, NodeEventType, UITransform, Tween, tween, Vec3 } from "cc";
import { _decorator, Node, Sprite, Label, RichText, resources, AnimationClip, SpriteFrame, NodeEventType, UITransform, Tween, tween, Vec3 } from "cc";
import { ecs } from "../../../../extensions/oops-plugin-framework/assets/libs/ecs/ECS";
import { CCComp } from "../../../../extensions/oops-plugin-framework/assets/module/common/CCComp";
import { oops } from "db://oops-framework/core/Oops";
@@ -20,7 +20,7 @@ import { GameEvent } from "../common/config/GameEvent";
import { HeroInfo } from "../common/config/heroSet";
import { HeroAttrsComp } from "../hero/HeroAttrsComp";
import { getLvColor } from "../common/config/GameSet";
import { buildSkillDesc } from "../common/config/HeroSkillDesc";
import { buildSkillDescRich } from "../common/config/HeroSkillDesc";
const { ccclass, property } = _decorator;
@@ -53,9 +53,11 @@ export class HeroBoxComp extends CCComp {
@property(Node)
private noHero: Node = null;
/** 技能描述标签(多行,展示当前等级触发技能) */
@property(Label)
private skill_label: Label = null;
/** 技能描述富文本(多行,展示当前等级触发技能,数值高亮 */
@property(RichText)
private skill_label: RichText = null;
/** 技能描述富文本内容缓存(避免 refresh 降频内重复重解析排版) */
private skillDescCache: string = "";
// ======================== 战斗信息标签(总值 + 附加值) ========================
@@ -329,6 +331,7 @@ export class HeroBoxComp extends CCComp {
if (this.skill_label && this.skill_label.isValid) {
this.skill_label.string = "";
}
this.skillDescCache = "";
}
/**
@@ -396,9 +399,14 @@ export class HeroBoxComp extends CCComp {
const finalCritDmg = this.model.getFinalCritDamage();
this.setStatRow(this.crit_dmg_all_label, this.crit_dmg_plus_label, finalCritDmg, finalCritDmg - (this.model.crit_damage ?? 0), "%");
// ---- 技能描述(当前等级触发技能,读运行时 model 最终配置) ----
// ---- 技能描述(当前等级触发技能,读运行时 model 最终配置;数值富文本高亮 ----
if (this.skill_label && this.skill_label.isValid) {
this.skill_label.string = buildSkillDesc(this.model);
// 缓存未变则跳过重设,避免 refresh 降频内 RichText 重复重解析排版
const desc = buildSkillDescRich(this.model);
if (desc !== this.skillDescCache) {
this.skillDescCache = desc;
this.skill_label.string = desc;
}
}
// ---- 图标UUID 变化时重新加载首帧动画) ----