Files
pixelheros/assets/script/game/common/HeroSkillText.ts
pan d929a99172 refactor(hero): 新增英雄技能文案生成模块HeroSkillText
1.  新增HeroSkillText.ts,实现基于内联模板的实时技能文案渲染,解决数值与文案不同步问题
2.  为HSkillInfo、LvSkillEntry、LvTriggerEntry新增desc模板字段,配置技能描述模板
3.  重构HeroSkillDesc.ts,新增buildEffectOnlyRich方法,兼容旧UI引用
4.  为所有英雄配置添加技能描述模板,替换原硬编码文案逻辑
2026-08-27 20:40:53 +08:00

416 lines
19 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 HeroSkillText.ts
* @description 英雄技能文案生成器(heroInfo 内联描述模板驱动)
*
* 与 HeroSkillDesc(SkillDescSet 中央模板表)并列的新一代文案层:
* - 描述模板直接配置在 heroInfo(HSkillInfo.desc / LvSkillEntry.desc / LvTriggerEntry.desc),
* "同一底座技能、不同英雄语境"的表述差异在英雄配置处表达;
* - 数值永远从 mergeSkillParams 合并后的实时配置取值(占位符渲染),
* 改 overrides 数值文案自动同步,杜绝"改数值忘改文案"漂移;
* - 英雄当前等级取自 SingletonModuleComp 的局外成长等级(smc.getHeroOuterLv)。
*
* 两个输出(均为富文本 BBCode,RichText 直接可用):
* 1. buildHeroSkillDesc(heroUuid) —— 当前局外等级下各技能最大生效档的完整说明
* 2. buildHeroSkillTierDesc(heroUuid, skillUuid) —— 指定技能各阶段说明:
* slv1 给完整能力 + 激活英雄等级;slv2+ 给相对上一级的提升 + 解锁英雄等级
*
* 占位符规范(模板作者可用,渲染时数值片段自动绿色高亮):
* {target} 目标语义(自身/敌方/全体友方/N名队友…,与 SCastSystem 目标选取一致)
* {dur} 计时 buff 持续秒数(buff_duration 优先,回退 BuffList 默认)
* {buff_value} 计时 buff 修饰数值(tick 类取绝对值展示,modifiers 类保留正负号)
* {字段名} 合并后技能配置数值(ap/para/frz/stun/crt/bck/hit_count/num/summon_count…)
*
* 依赖:heroSet(配置与 resolver)、SkillSet/BuffSet/SkillDescSet(底座)、smc(局外等级)。
* HeroSkillDesc.ts 暂时保留兼容旧 UI 引用,待迁移完成后移除。
*/
import {
HeroInfo, SkillTriggerType, SkillTriggerName, HSkillInfo, LvSkillEntry, LvTriggerEntry,
TriggerGrouped, resolveSkillOverrides, resolveTriggerByLv, resolveFieldByLv, resolveReviveByLv,
getGroupedTriggerName, getFieldSkillName, getReviveSkillName,
} from "./config/heroSet";
import { SkillSet, mergeSkillParams, SkillConfig, SkillKind, TGroup, FieldSkillSet } from "./config/SkillSet";
import { BuffList } from "./config/BuffSet";
import { FieldDescSet, FieldValueFmt } from "./config/SkillDescSet";
import { FacSet } from "./config/GameSet";
import { smc } from "./SingletonModuleComp";
/* ==================== 富文本片段(与 HeroSkillDesc 同配色,保证新旧面板视觉一致) ==================== */
/** 富文本数值高亮色(鲜亮绿色) */
const RICH_VALUE_COLOR = "#3CE66E";
/** 数值描边色(深绿,衬托亮绿填充) */
const RICH_VALUE_OUTLINE = "#145A32";
/** 技能名高亮色(蜜桃橙) */
const RICH_NAME_COLOR = "#F8C471";
/** 技能名描边色(深褐) */
const RICH_NAME_OUTLINE = "#6E2C00";
/** 数值富文本片段:亮绿填充 + 深绿描边 */
function valueRich(text: string | number): string {
return `<outline color=${RICH_VALUE_OUTLINE} width=1><color=${RICH_VALUE_COLOR}>${text}</color></outline>`;
}
/** 技能名富文本片段:「技能名」蜜桃橙填充 + 深褐描边 */
function skillNameRich(name: string): string {
return `<outline color=${RICH_NAME_OUTLINE} width=1><color=${RICH_NAME_COLOR}>「${name}」</color></outline>`;
}
/* ==================== 模板渲染基础 ==================== */
/** 需要遍历的触发技能类型(field/revive 结构不同,单独处理) */
const TRIGGER_KEYS: SkillTriggerType[] = [
SkillTriggerType.Call,
SkillTriggerType.Dead,
SkillTriggerType.FStart,
SkillTriggerType.Atking,
SkillTriggerType.Atked,
];
/** 占位符模式:{target}/{dur}/{buff_value}/{技能字段名} */
const PLACEHOLDER_PATTERN = /\{(\w+)\}/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));
// 治疗类命中低血量队友
if (skill.kind === SkillKind.Heal) {
return n > 1 ? `${n}名低血量队友` : "低血量队友";
}
return `${n}名队友`;
}
default:
return "全体友方";
}
}
/** 计时 buff 生效时长(overrides.buff_duration 优先,回退 BuffList 默认) */
function getBuffDuration(skill: SkillConfig): number {
if (skill.timed_buff_id === undefined) return 0;
return skill.buff_duration ?? BuffList[skill.timed_buff_id]?.duration ?? 0;
}
/** 计时 buff 修饰数值(tick 类伤害取绝对值展示;modifiers 类保留正负号表达增减) */
function getBuffValue(skill: SkillConfig): number {
if (skill.timed_buff_id === undefined) return skill.buff_value ?? 0;
if (skill.buff_value !== undefined) return skill.buff_value;
const buff = BuffList[skill.timed_buff_id];
if (!buff) return 0;
if (buff.modifiers?.length) return buff.modifiers[0].value;
if (buff.tick) return Math.abs(buff.tick.damage_or_heal);
return 0;
}
/**
* 渲染 heroInfo 内联描述模板:数值占位符替换为合并后配置实时值并绿色高亮。
* Why: 数值不写死在模板里,overrides 调整档位数值时文案自动同步。
* @param tmpl 描述模板(含占位符)
* @param skill mergeSkillParams 合并后的技能配置
*/
function renderDesc(tmpl: string, skill: SkillConfig): string {
return tmpl.replace(PLACEHOLDER_PATTERN, (raw, key: string) => {
if (key === "target") return buildTargetDesc(skill);
if (key === "dur") return valueRich(getBuffDuration(skill));
if (key === "buff_value") return valueRich(getBuffValue(skill));
const v = (skill as unknown as Record<string, unknown>)[key];
// 未定义数值字段按 0 渲染,避免面板出现 undefined(模板与配置不匹配的兜底)
return v === undefined ? valueRich(0) : valueRich(String(v));
});
}
/** 无模板时兜底:回退底座技能的 info 备注 */
function fallbackDesc(skill: SkillConfig): string {
return String(skill.info ?? "");
}
/** 取英雄展示等级:英雄用局外成长等级(smc),怪物无成长直接用配置 lv */
function getHeroDisplayLv(heroUuid: number, configLv: number, fac: FacSet): number {
if (fac !== FacSet.HERO) return Math.max(1, configLv);
return Math.max(1, smc.getHeroOuterLv(heroUuid));
}
/* ==================== 输出一:当前等级最大生效档说明 ==================== */
/**
* 输出一:英雄当前局外等级下,全部技能最大生效档的完整说明(富文本)。
*
* 覆盖:普攻/大招(skills,resolve ≤lv 最大档)、5 类触发技能、驻场光环(field)、
* 复活(revive)。每行 = 「技能名」+ 触发语境 + 模板渲染后的效果文本(数值高亮)。
*
* @param heroUuid 英雄 uuid(HeroInfo key)
* @param showName 是否包含「技能名」片段(面板 true,紧凑卡片 false)
* @returns 多行富文本串,\n 分隔;英雄不存在时返回 ""
*/
export function buildHeroSkillDesc(heroUuid: number, showName: boolean = true): string {
const hero = HeroInfo[heroUuid];
if (!hero) return "";
const lv = getHeroDisplayLv(heroUuid, hero.lv, hero.fac);
const lines: string[] = [];
const nameSeg = (n: string) => (showName && n ? `${skillNameRich(n)} ` : "");
// ---- 普攻/大招(skills):resolve 当前等级生效档,渲染该档模板 ----
for (const key in hero.skills) {
const hSkill = hero.skills[key];
if (!hSkill) continue;
const base = SkillSet[hSkill.uuid];
if (!base) continue;
const skill = mergeSkillParams(base, resolveSkillOverrides(hSkill, lv));
// 生效档(≤lv 最大档):取其 s_lv 与 desc;未配置 desc 的档位继承上一档模板
let entry: LvSkillEntry | undefined;
let inheritDesc = hSkill.desc;
for (const e of hSkill.lv_entries ?? []) {
if (e.lv <= lv) {
if (!entry || e.lv > entry.lv) entry = e;
if (e.desc) inheritDesc = e.desc;
}
}
const sLv = entry?.s_lv ?? hSkill.lv;
const text = inheritDesc ? renderDesc(inheritDesc, skill) : fallbackDesc(skill);
lines.push(`${nameSeg(hSkill.name ?? base.name)}Lv.${valueRich(sLv)}: ${text}`);
}
// ---- 5 类标准触发技能:resolve 当前等级生效档 ----
for (const type of TRIGGER_KEYS) {
const grouped = hero[type] as TriggerGrouped | undefined;
if (!grouped) continue;
for (const item of resolveTriggerByLv(grouped, lv)) {
const base = SkillSet[item.s_uuid];
if (!base) continue;
const skill = mergeSkillParams(base, item.overrides);
// 档位模板继承链:组内 ≤lv 档依次覆盖(后配置的生效),缺省回退第 1 档
const entries = grouped[item.s_uuid] ?? [];
let tmpl: string | undefined;
for (const e of entries) {
if (e.lv <= lv && e.desc) tmpl = e.desc;
}
tmpl = tmpl ?? entries[0]?.desc;
const text = tmpl ? renderDesc(tmpl, skill) : fallbackDesc(skill);
// 进击/受击带触发次数,拼为"攻击3次";其余用触发类型简称
const trig = type === SkillTriggerType.Atking || type === SkillTriggerType.Atked
? `${SkillTriggerName[type]}${valueRich(item.t_num)}次`
: SkillTriggerName[type];
lines.push(`${nameSeg(getGroupedTriggerName(grouped, item.s_uuid))}${trig}, ${text}`);
}
}
// ---- 驻场光环(field):查 FieldDescSet 按 type 渲染,数值实时高亮 ----
for (const entry of resolveFieldByLv(hero.field, lv)) {
const fs = FieldSkillSet[entry.uuid];
const desc = fs ? FieldDescSet[fs.type] : undefined;
if (!fs || !desc) continue;
const val = desc.fmt === FieldValueFmt.Percent
? `+${Math.round(entry.value * 100)}%`
: desc.fmt === FieldValueFmt.IntMinus ? `-${entry.value}` : `+${entry.value}`;
lines.push(`${nameSeg(getFieldSkillName(entry.uuid))}${desc.verb}${valueRich(val)}`);
}
// ---- 复活(revive):次数复刻战斗公式 r_num + floor((lv-1)*upr) ----
const revive = resolveReviveByLv(hero.revive, lv);
if (revive) {
const count = revive.r_num + Math.floor((lv - 1) * revive.upr);
const hpPct = SkillSet[revive.s_uuid]?.ap ?? 0;
lines.push(`${nameSeg(getReviveSkillName())}每回合可复活${valueRich(count)}次, 死亡后以${valueRich(`${Math.round(hpPct)}%`)}生命原地重生`);
}
return lines.join("\n");
}
/* ==================== 输出二:指定技能各阶段(分档)说明 ==================== */
/** 一个技能等级档位的统一结构(普攻 lv_entries 与触发技能 LvTriggerEntry 归一) */
interface ISkillTierInfo {
/** 技能等级(展示用,取 s_lv,缺省按档位序号) */
sLv: number;
/** 该档生效的英雄等级(激活/解锁提示) */
heroLv: number;
/** 合并底座后的完整技能配置 */
skill: SkillConfig;
/** 该档描述模板(继承链解析后) */
desc?: string;
/** 触发技能档位的触发次数(普攻无) */
tNum: number;
}
/** 相邻档 diff 参与展示的字段(排除动画/弹道/特效等玩家无感字段),t_num 为触发技能专有 */
const DIFF_KEYS: string[] = [
"ap", "hit_count", "num", "summon_count", "crt", "frz", "stun", "para",
"bck", "bck_chance", "buff_duration", "buff_value", "t_num", "timed_buff_id",
];
/** diff 字段的称谓与单位("新增/漂移"描述共用) */
const DIFF_META: Record<string, { label: string; unit: string }> = {
ap: { label: "伤害", unit: "%" },
hit_count: { label: "命中", unit: "次" },
num: { label: "双发", unit: "次" },
summon_count: { label: "召唤数", unit: "" },
crt: { label: "暴击", unit: "%" },
frz: { label: "冰冻", unit: "%" },
stun: { label: "击晕", unit: "%" },
para: { label: "麻痹", unit: "%" },
bck: { label: "击退", unit: "" },
bck_chance: { label: "击退概率", unit: "%" },
buff_duration: { label: "持续", unit: "秒" },
buff_value: { label: "增益强度", unit: "" },
t_num: { label: "触发次数", unit: "次" },
};
/** 读取档位某展示字段的实时值(t_num/buff_value 走专用取值) */
function getTierValue(tier: ISkillTierInfo, key: string): number {
if (key === "t_num") return tier.tNum;
if (key === "buff_value") return getBuffValue(tier.skill);
const v = (tier.skill as unknown as Record<string, unknown>)[key];
return typeof v === "number" ? v : 0;
}
/**
* 相邻两档 diff:数值漂移(差值)+ 新功能(上级为 0 本级新增)+ 计时 buff 增减。
* Why: slv2+ 只描述"多得到了什么",让玩家一眼看清每级提升。
*/
function buildTierDiff(prev: ISkillTierInfo, cur: ISkillTierInfo): string[] {
const parts: string[] = [];
for (const key of DIFF_KEYS) {
// 计时 buff 增减单独处理:展示 buff 名而非内部 id
if (key === "timed_buff_id") {
const pb = prev.skill.timed_buff_id ?? 0;
const cb = cur.skill.timed_buff_id ?? 0;
if (pb === cb) continue;
if (!pb && cb) parts.push(`新增${valueRich(BuffList[cb]?.name ?? "计时增益")}`);
else if (pb && !cb) parts.push(`移除${valueRich(BuffList[pb]?.name ?? "计时增益")}`);
else parts.push(`${valueRich(BuffList[pb]?.name ?? "计时增益")}→${valueRich(BuffList[cb]?.name ?? "计时增益")}`);
continue;
}
const meta = DIFF_META[key];
if (!meta) continue;
const pv = getTierValue(prev, key);
const cv = getTierValue(cur, key);
if (cv === pv) continue;
if (pv === 0 && cv > 0) {
parts.push(`新增${meta.label}${valueRich(`${cv}${meta.unit}`)}`);
} else {
const d = cv - pv;
parts.push(`${meta.label}${valueRich(`${d > 0 ? "+" : ""}${d}${meta.unit}`)}`);
}
}
return parts;
}
/** 收集普攻/大招(skills)的全部等级档位(slv 升序,模板走继承链) */
function collectSkillTiers(hSkill: HSkillInfo): ISkillTierInfo[] {
const base = SkillSet[hSkill.uuid];
if (!base) return [];
const entries = [...(hSkill.lv_entries ?? [])].sort((a, b) => (a.s_lv ?? 0) - (b.s_lv ?? 0));
const tiers: ISkillTierInfo[] = [];
// slv1 = 基础档(底座 + 基础 overrides),激活等级取 s_lv≤1 档的 lv(缺省 1)
const lv1 = entries.find(e => (e.s_lv ?? 0) <= 1);
tiers.push({
sLv: lv1?.s_lv ?? 1,
heroLv: lv1?.lv ?? 1,
skill: mergeSkillParams(base, hSkill.overrides),
desc: lv1?.desc ?? hSkill.desc,
tNum: 0,
});
// slv2+ = lv_entries 各档(与基础 overrides 浅合并,档位字段优先)
for (const e of entries) {
if ((e.s_lv ?? 0) < 2) continue;
tiers.push({
sLv: e.s_lv ?? 0,
heroLv: e.lv,
skill: mergeSkillParams(mergeSkillParams(base, hSkill.overrides), e.overrides),
desc: e.desc,
tNum: 0,
});
}
// 模板继承链:未配置 desc 的档位沿用上一档模板(机制不变仅数值变化的档位免配)
let inherit: string | undefined;
for (const t of tiers) {
if (t.desc) inherit = t.desc;
else t.desc = inherit;
}
return tiers;
}
/**
* 输出二:指定技能的各阶段说明(富文本)。
*
* 输出结构(按技能等级升序,通用支持任意档数):
* - slv1:完整能力 + 数值 +(英雄X级激活)
* - slv2+:相对上一级的提升(数值漂移/新增效果/计时 buff 增减)+(英雄Y级解锁)
*
* 技能定位顺序:hero.skills[skillUuid](普攻/大招)→ 5 类触发技能组 → 未找到返回 ""。
* Why: 同一底座技能可能被多个英雄以不同 overrides 引用,必须以英雄语境取档。
*
* @param heroUuid 英雄 uuid(HeroInfo key)
* @param skillUuid 技能 uuid(skills key 或触发组 s_uuid)
* @param showName 是否包含「技能名」片段
* @returns 多行富文本串,\n 分隔;技能不属于该英雄时返回 ""
*/
export function buildHeroSkillTierDesc(heroUuid: number, skillUuid: number, showName: boolean = true): string {
const hero = HeroInfo[heroUuid];
if (!hero) return "";
let name = "";
let tiers: ISkillTierInfo[] = [];
let triggerLabel = "";
const hSkill = hero.skills?.[skillUuid];
if (hSkill) {
// 普攻/大招路径
name = hSkill.name ?? SkillSet[skillUuid]?.name ?? "";
tiers = collectSkillTiers(hSkill);
} else {
// 触发技能路径:在 5 类触发组中定位 s_uuid
for (const type of TRIGGER_KEYS) {
const grouped = hero[type] as TriggerGrouped | undefined;
const entries = grouped?.[skillUuid];
if (!grouped || !entries?.length) continue;
const base = SkillSet[skillUuid];
if (!base) return "";
const sorted = [...entries].sort((a, b) => (a.s_lv ?? 0) - (b.s_lv ?? 0));
name = getGroupedTriggerName(grouped, skillUuid);
let inherit: string | undefined;
tiers = sorted.map((e: LvTriggerEntry, i: number) => {
if (e.desc) inherit = e.desc;
return {
sLv: e.s_lv ?? (i + 1),
heroLv: e.lv,
skill: mergeSkillParams(base, e.overrides),
desc: e.desc ?? inherit,
tNum: e.t_num,
};
});
triggerLabel = (type === SkillTriggerType.Atking || type === SkillTriggerType.Atked)
? `${SkillTriggerName[type]}${valueRich(sorted[0].t_num)}次`
: SkillTriggerName[type];
break;
}
}
if (tiers.length === 0) return "";
const nameSeg = showName && name ? `${skillNameRich(name)} ` : "";
const lines: string[] = [];
for (let i = 0; i < tiers.length; i++) {
const t = tiers[i];
const head = `${nameSeg}Lv.${valueRich(t.sLv)}(英雄${valueRich(t.heroLv)}级${i === 0 ? "激活" : "解锁"})`;
if (i === 0) {
// 技能 1 级:完整能力 + 数值(含激活英雄等级)
const text = t.desc ? renderDesc(t.desc, t.skill) : fallbackDesc(t.skill);
lines.push(`${head}: ${triggerLabel ? `${triggerLabel}, ` : ""}${text}`);
} else {
// 技能 2 级起:相对上一级的提升 + 解锁英雄等级
const diff = buildTierDiff(tiers[i - 1], t);
lines.push(`${head}: ${diff.length > 0 ? diff.join(",") : "无变化"}`);
}
}
return lines.join("\n");
}