Files
pixelheros/assets/script/game/hero/HeroAtkSystem.ts
pan be17d18ff3 feat(skill-system): 新增计时debuff机制,支持技能命中敌方附加减益效果
本次提交完整实现了技能对敌附加debuff的完整流程:
1.  新增TimedDebuffSnapshot数据结构,用于透传debuff配置
2.  在SkillConfig和SkillOverrides中新增debuff相关配置项
3.  实现HeroAtkSystem中debuff的判定与施加逻辑
4.  完善后台配置、前端编辑器的debuff配置支持
5.  调整了buff目标判定逻辑,修复了原有默认目标的语义混淆问题
6.  优化了BuffSystem的调试日志,新增了debuff分类标识
7.  批量更新了现有英雄技能的debuff配置示例
2026-09-08 16:37:13 +08:00

823 lines
38 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.
import { ecs } from "../../../../extensions/oops-plugin-framework/assets/libs/ecs/ECS";
import { FacSet } from "../common/config/GameSet";
import { Attrs } from "../common/config/HeroAttrs";
import { FightSet } from "../common/config/GameSet";
import { SkillSet } from "../common/config/SkillSet";
import { HeroAttrsComp } from "./HeroAttrsComp";
import { HeroViewComp } from "./HeroViewComp";
import { DamageQueueComp, DamageEvent } from "./DamageQueueComp";
import { smc } from "../common/SingletonModuleComp";
import { HeroInfo } from "../common/config/heroSet";
import { oops } from "db://oops-framework/core/Oops";
import { GameEvent } from "../common/config/GameEvent";
import { SkillTriggerType } from "../common/config/heroSet";
import { FieldSkillType, BuffTarget } from "../common/config/SkillSet";
import { mLogger } from "../common/Logger";
import { SkillTriggerHelper } from "./SkillTriggerHelper";
import { getMonsterGoldDrop } from "../map/RogueConfig";
import { MissionEconomy } from "../map/MissionEconomy";
import { MoveComp } from "./MoveComp";
import { buffManager, BuffApplyOverrides } from "./BuffManager";
import { DEBUFF_ICON_MAP, VULNERABLE_BUFF_ID, BLEED_BUFF_ID, BLEED_CRIT_PER_STACK, BuffList } from "../common/config/BuffSet";
import { BuffComp } from "./BuffComp";
/** 最终伤害数据接口
* 用于封装一次攻击计算的所有结果数据
* @property damage - 最终造成的伤害值(已考虑所有加成和减免)
* @property isCrit - 是否为暴击攻击
*/
interface FinalData {
damage: number;
isCrit: boolean;
}
/**
* 英雄攻击系统 - 伤害处理核心系统
*
* 职责:
* 1. 处理所有伤害事件的计算和分发
* 2. 管理伤害队列的处理流程
* 3. 协调视图层的表现更新
*
* 重要概念:
* - damageEvent.Attrs: 施法者属性快照(创建技能时保存)
* - TAttrsComp: 被攻击者实时属性
* - 属性来源规范:攻击判定用施法者,防御判定用被攻击者
*/
@ecs.register('HeroAtkSystem')
export class HeroAtkSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate {
private debugMode: boolean = true; // 是否启用调试模式
/**
* 过滤器:处理拥有伤害队列的实体
*/
filter(): ecs.IMatcher {
return ecs.allOf(HeroAttrsComp, DamageQueueComp);
}
/**
* 系统更新(每帧调用)
* 处理伤害队列中的所有伤害事件
*/
update(e: ecs.Entity): void {
if (!smc.mission.play) return
if (smc.mission.pause) return
const TAttrsComp = e.get(HeroAttrsComp)
const damageQueue = e.get(DamageQueueComp)
if (!TAttrsComp || !damageQueue || damageQueue.isEmpty()) return;
// 标记正在处理
damageQueue.isProcessing = true;
// 处理队列中的所有伤害事件
let processedCount = 0;
while (!damageQueue.isEmpty()) {
const damageEvent = damageQueue.getNextDamageEvent();
if (!damageEvent) break;
// 处理单个伤害事件
this.doAttack(e, damageEvent);
processedCount++;
damageQueue.processedCount++;
// 如果目标已死亡,停止处理后续伤害
if (TAttrsComp.is_dead) {
mLogger.log(this.debugMode, 'HeroAtkSystem', ` ${TAttrsComp.hero_name} 已死亡,停止处理剩余伤害`);
damageQueue.clear(); // 清空剩余伤害
break;
}
}
// 如果队列已空,移除伤害队列组件
if (damageQueue.isEmpty()) {
e.remove(DamageQueueComp);
if (processedCount > 0) {
mLogger.log(this.debugMode, 'HeroAtkSystem', ` ${TAttrsComp.hero_name} 伤害队列处理完成,共处理 ${processedCount} 个伤害事件`);
}
}
}
/** 检查并触发受击附加技能 (atked) */
private checkAndTriggerAtkedSkills(heroAttrs: HeroAttrsComp, heroView: HeroViewComp) {
SkillTriggerHelper.trigger(SkillTriggerType.Atked, heroAttrs, heroView);
}
/**
* 执行攻击计算 - 核心伤害计算逻辑
*
* @param target 目标实体(被攻击者)
* @param damageEvent 伤害事件数据(包含施法者信息和属性快照)
* @returns 最终伤害数据(包含伤害值、暴击标记、闪避标记)
*/
private doAttack(target: ecs.Entity, damageEvent: DamageEvent): FinalData {
const TAttrsComp = target.get(HeroAttrsComp);
const targetView = target.get(HeroViewComp);
let reDate: FinalData = {
damage: 0,
isCrit: false,
}
if (!TAttrsComp || TAttrsComp.is_dead || TAttrsComp.is_reviving) return reDate;
const casterEid = damageEvent.casterEid;
// DoT 事件:dot_dmg>0 时伤害为固定 tick 值,无对应 SkillSet 配置(s_uuid 位置是 buffId)
const isDot = (damageEvent.dot_dmg ?? 0) > 0;
// DoT 事件的 Attrs 来自 ActiveBuff.source_attrs_snapshot,可能为空,兜底空对象避免取属性报错
if (isDot && !damageEvent.Attrs) damageEvent.Attrs = {};
// 获取技能配置
const skillConf = SkillSet[damageEvent.s_uuid];
if (!skillConf && !isDot) return reDate;
// 触发被攻击事件
this.onAttacked(target);
// 闪避判定(DoT 不参与):闪避率 = 目标闪避 − 施法者命中
if (!isDot && this.checkDodge(damageEvent, TAttrsComp)) {
smc.vmdata.scores.dod_count++;
targetView?.showMiss?.();
// 预留:闪避后触发技能(SkillTriggerType.Dodged)在此接入
return reDate;
}
// 暴击判定
// 使用施法者的暴击率属性(damageEvent.Attrs 快照)- 被攻击者的暴击抗性 + 目标出血层数附加的被暴击率
const criticalChance = (damageEvent.Attrs[Attrs.critical] || 0) - TAttrsComp.getFinalCriticalRes() + this.getBleedCritBonus(target);
const isCrit = this.checkChance(criticalChance, `暴击->${TAttrsComp.hero_name}`);
// 计算基础伤害:DoT 直接用固定 tick 值,普攻走 dmgCount 公式
let damage = isDot ? (damageEvent.dot_dmg ?? 0) : this.dmgCount(damageEvent, TAttrsComp);
mLogger.log(this.debugMode, 'HeroAtkSystem', " dmgCount", damage)
if (isCrit) {
const critDamageBonus = damageEvent.Attrs[Attrs.critical_damage] || 0;
damage = Math.floor(damage * (1 + (FightSet.CRIT_DAMAGE + critDamageBonus) / 100));
reDate.isCrit = true;
if (damageEvent.Attrs.fac === FacSet.HERO) {
// 【评分系统 - 输出分】统计暴击次数与暴击造成的总伤害
smc.vmdata.scores.crt_count++;
smc.vmdata.scores.crit_dmg_total += damage;
}
}
mLogger.log(this.debugMode, 'HeroAtkSystem', " after crit", damage)
// 易伤附加:目标每层易伤 buff 附加固定伤害,被易伤抗性百分比减免;普攻与 DoT 均生效
damage += this.calcVulnerableBonus(target, TAttrsComp);
mLogger.log(this.debugMode, 'HeroAtkSystem', " after vulnerable", damage)
// 护盾数值减免(1 点护盾吸收 1 点伤害,扣完为止,溢出部分继续扣血)
const shieldResult = this.absorbShield(TAttrsComp, damage);
damage = shieldResult.remainingDamage;
if (shieldResult.absorbedDamage > 0) {
// 【评分系统 - 防御分】统计护盾累计吸收的伤害量
smc.vmdata.scores.shield_block_count += Math.floor(shieldResult.absorbedDamage);
}
mLogger.log(this.debugMode, 'HeroAtkSystem', " after shield", damage)
// 显示护盾减免飘字
if (shieldResult.absorbedDamage > 0 && targetView) {
targetView.shield_tip(shieldResult.absorbedDamage);
}
if (damage <= 0) return reDate;
// 防御减伤(除法递减模型,护盾吸收之后、扣血之前结算)
damage = TAttrsComp.applyDefense(damage);
mLogger.log(this.debugMode, 'HeroAtkSystem', " after defense", damage)
// TAttrsComp.hp -= damage; // 应用伤害到数据层
TAttrsComp.add_hp(-damage); // 使用 add_hp 以触发 dirty_hp 和 UI 更新
if (damageEvent.Attrs.fac === FacSet.HERO) {
// 【评分系统 - 输出分】统计团队造成的总伤害以及单次最高伤害记录
smc.vmdata.scores.total_dmg += damage;
if (damage > smc.vmdata.scores.highest_dmg) {
smc.vmdata.scores.highest_dmg = damage;
}
}
// 受击计数:只有实际造成伤害才 +1(被格挡不计数);DoT 不计受击、不触发受击技
if (!isDot) {
TAttrsComp.atked_count++;
this.checkAndTriggerAtkedSkills(TAttrsComp, targetView);
}
// 反伤结算:实际受到伤害后,目标将自身 thorns_dmg 反弹给攻击者
// 反伤值 = 实际受到伤害 * thorns_rate / 100 + thorns_dmg(固定值+比例混合)
// DoT 不触发反伤(持续伤害类效果通常不触发荆棘)
if (!isDot && damage > 0) {
const thornsDmg = Math.floor(damage * TAttrsComp.getFinalThornsRate() / 100) + TAttrsComp.getFinalThornsDmg();
if (thornsDmg > 0) {
this.applyThornsDamage(casterEid, thornsDmg);
}
}
mLogger.log(this.debugMode, 'HeroAtkSystem', ` 英雄${TAttrsComp.hero_name} (uuid: ${TAttrsComp.hero_uuid}) 受到 eid:${casterEid} 的 伤害 ${damage},${isCrit ? "暴击" : "普通"}攻击,技能ID ${damageEvent.s_uuid}`);
// 冰冻判定
const freezeChance = (damageEvent.Attrs[Attrs.freeze_chance] || 0) - (TAttrsComp.freeze_res || 0);
const isFrost = !TAttrsComp.isFrost() && this.checkChance(freezeChance, `冰冻->${TAttrsComp.hero_name}`);
// 击晕判定
const stunChance = (damageEvent.Attrs[Attrs.stun_chance] || 0) - TAttrsComp.getFinalStunRes();
const isStun = !TAttrsComp.isStun() && this.checkChance(stunChance, `击晕->${TAttrsComp.hero_name}`);
// 麻痹判定(无麻痹抗性,仅按概率)
const paralyzeChance = damageEvent.Attrs[Attrs.paralyze_chance] || 0;
const isParalyze = !TAttrsComp.isParalyze() && this.checkChance(paralyzeChance, `麻痹->${TAttrsComp.hero_name}`);
// ✅ 触发视图层表现(伤害数字、受击动画、冰冻、击晕、击退)
if (targetView) {
if (isDot) {
// DoT:只飘伤害数字 + 刷新血条,不播受击动画/音效/击退;
// s_uuid 位置是 buffId,据此激活 loss_life/hp/debuff 下的对应 debuff 图标
targetView.showDamage(damage, isCrit, DEBUFF_ICON_MAP[damageEvent.s_uuid] ?? "");
} else {
// 击退概率判定:概率来自技能配置 bck_chance(heroSet overrides 可覆写),未配置视为 0 不击退。
// 不复用 checkChance:其 70% 上限封顶会把 100% 击退(近战普攻 6004)削成 70%
const knockbackChance = damageEvent.Attrs[Attrs.knockback_skill_chance] || 0;
const knockbackRand = knockbackChance >= 100 ? 0 : Math.random() * 100;
const isKnockback = knockbackChance >= 100 || (knockbackChance > 0 && knockbackRand < knockbackChance);
if (knockbackChance > 0) {
mLogger.log(this.debugMode, 'HeroAtkSystem', `[概率判定] 击退->${TAttrsComp.hero_name} 概率:${knockbackChance.toFixed(1)}% 随机:${knockbackRand.toFixed(1)} -> ${isKnockback ? "✔成功" : "✘失败"}`);
}
// 击退距离增量 = 施法者击退强化(原击退概率)- 被攻击者击退抗性 + 击退距离强化(仅命中击退时生效)
const knockbackBonus = (damageEvent.Attrs[Attrs.knockback_chance] || 0) - (TAttrsComp.getFinalKnockbackRes() || 0);
const knockbackExtra = Math.max(0, knockbackBonus + (damageEvent.Attrs[Attrs.knockback_distance] || 0));
targetView.do_atked(damage, isCrit, damageEvent.s_uuid, isKnockback ? knockbackExtra : 0);
targetView.playEnd(skillConf.endAnm);
// 击退位移下沉到 MoveSystem:直接写入 knockbackVelocity,由 MoveSystem 每帧衰减并叠加位移
if (isKnockback) {
const moveComp = target.get(MoveComp);
if (moveComp) {
const dist = FightSet.BACK_RANG + knockbackExtra;
const isHero = TAttrsComp.fac === FacSet.HERO;
// 击退初速度:0.1 秒时间常数衰减,总位移约等于 dist
const knockbackSpeed = dist * 16;
moveComp.knockbackVelocity = isHero ? -knockbackSpeed : knockbackSpeed;
}
}
}
if (isFrost) {
TAttrsComp.toFrost();
targetView.in_iced(FightSet.FROST_TIME);
if (damageEvent.Attrs.fac === FacSet.HERO) {
smc.vmdata.scores.freeze_count++;
}
}
if (isStun) {
TAttrsComp.toStun();
targetView.in_stun(FightSet.STUN_TIME);
if (damageEvent.Attrs.fac === FacSet.HERO) {
smc.vmdata.scores.stun_count++;
}
}
if (isParalyze) {
TAttrsComp.toParalyze();
}
}
// 命中附带 buff:仅普攻路径;buff 语义为增益,缺省挂施法者自身(如吸血/攻击后强化),
// 显式配置 buff_target 可改为全体友方等;buff 也可挂自身 debuff 类(如自残出血)
// 施法者 ap/完整属性快照取自伤害事件,供 BuffSystem tick 缩放与暴击/冰冻/击晕判定
if (!isDot && skillConf.timed_buff_id) {
const buffChance = skillConf.buff_chance ?? 100;
const buffRand = buffChance >= 100 ? 0 : Math.random() * 100;
if (buffChance >= 100 || buffRand < buffChance) {
const buffOverrides: BuffApplyOverrides = { source_attrs: damageEvent.Attrs };
if (skillConf.buff_value !== undefined) buffOverrides.value = skillConf.buff_value;
if (skillConf.buff_duration !== undefined) buffOverrides.duration = skillConf.buff_duration;
const buffTargets = this.resolveBuffTargets(target, casterEid, TAttrsComp.fac, skillConf.buff_target);
mLogger.log(this.debugMode, 'HeroAtkSystem', `[概率判定] 附带buff「${BuffList[skillConf.timed_buff_id]?.name ?? "?"}」 概率:${buffChance.toFixed(1)}% 随机:${buffRand.toFixed(1)} -> ✔成功(技能「${skillConf.name}」命中 ${TAttrsComp.hero_name},${buffTargets.length} 个目标)`);
for (const buffTarget of buffTargets) {
buffManager.applyBuff(buffTarget, skillConf.timed_buff_id, casterEid, damageEvent.Attrs[Attrs.ap] || 0, buffOverrides);
}
} else {
mLogger.log(this.debugMode, 'HeroAtkSystem', `[概率判定] 附带buff「${BuffList[skillConf.timed_buff_id]?.name ?? "?"}」 概率:${buffChance.toFixed(1)}% 随机:${buffRand.toFixed(1)} -> ✘失败(技能「${skillConf.name}」命中 ${TAttrsComp.hero_name})`);
}
}
// 命中附带 debuff:仅普攻路径;debuff 语义为对敌减益,目标固定为被命中者(不可覆写)
// 优先取伤害事件透传的合并后快照(heroSet overrides/lv_entries 注入),缺省回退 SkillSet 底座配置
// debuff_value/debuff_duration 覆写 BuffList 默认数值与持续时间
// 注:DoT 事件无 SkillSet 配置(skillConf 可能为 undefined),必须在 !isDot 保护内读取
if (!isDot && skillConf) {
const snapDebuff = damageEvent.timed_debuff;
const debuffId = snapDebuff?.id ?? skillConf.timed_debuff_id;
if (debuffId) {
const debuffChance = snapDebuff?.chance ?? skillConf.debuff_chance ?? 100;
const debuffRand = debuffChance >= 100 ? 0 : Math.random() * 100;
if (debuffChance >= 100 || debuffRand < debuffChance) {
const debuffOverrides: BuffApplyOverrides = { source_attrs: damageEvent.Attrs };
const debuffValue = snapDebuff?.value ?? skillConf.debuff_value;
const debuffDuration = snapDebuff?.duration ?? skillConf.debuff_duration;
if (debuffValue !== undefined) debuffOverrides.value = debuffValue;
if (debuffDuration !== undefined) debuffOverrides.duration = debuffDuration;
mLogger.log(this.debugMode, 'HeroAtkSystem', `[概率判定] 附带debuff「${BuffList[debuffId]?.name ?? "?"}」 概率:${debuffChance.toFixed(1)}% 随机:${debuffRand.toFixed(1)} -> ✔成功(技能「${skillConf.name}」命中 ${TAttrsComp.hero_name})`);
buffManager.applyBuff(target, debuffId, casterEid, damageEvent.Attrs[Attrs.ap] || 0, debuffOverrides);
} else {
mLogger.log(this.debugMode, 'HeroAtkSystem', `[概率判定] 附带debuff「${BuffList[debuffId]?.name ?? "?"}」 概率:${debuffChance.toFixed(1)}% 随机:${debuffRand.toFixed(1)} -> ✘失败(技能「${skillConf.name}」命中 ${TAttrsComp.hero_name})`);
}
}
}
// 命中概率触发额外技能(proc_skill):配置由施法技能实体快照自合并后配置(含 heroSet overrides/lv_entries),
// 触发走 SkillTriggerHelper/forceCastTriggerSkill 通道(无视 CD、无施法前摇),与 atking 同一通道。
// 防递归:被触发的技能经 forceCastTriggerSkill 不再产生 proc_skill 伤害事件之外的新事件,且不会累加 atk_count。
if (!isDot && damageEvent.proc_skill) {
const proc = damageEvent.proc_skill;
const procChance = proc.chance ?? 100;
const procRand = procChance >= 100 ? 0 : Math.random() * 100;
if (procChance >= 100 || procRand < procChance) {
const caster = ecs.getEntityByEid(casterEid);
if (caster) {
const casterAttrs = caster.get(HeroAttrsComp);
const casterView = caster.get(HeroViewComp);
if (casterAttrs && casterView && !casterAttrs.is_dead && !casterAttrs.is_reviving) {
mLogger.log(this.debugMode, 'HeroAtkSystem', `[概率判定] 额外技能「${SkillSet[proc.s_uuid]?.name ?? "?"}」 概率:${procChance.toFixed(1)}% 随机:${procRand.toFixed(1)} -> ✔成功(技能「${skillConf.name}」命中 ${TAttrsComp.hero_name},由 ${casterAttrs.hero_name} 释放)`);
// proc_skill.overrides 透传给被触发技能(如自定义 ap 伤害),与被触发技能自身 overrides 叠加(本次传入优先)
SkillTriggerHelper.dispatchSinglePublic(proc.s_uuid, casterAttrs, casterView, SkillTriggerType.Atking, proc.overrides);
}
}
} else {
mLogger.log(this.debugMode, 'HeroAtkSystem', `[概率判定] 额外技能「${SkillSet[proc.s_uuid]?.name ?? "?"}」 概率:${procChance.toFixed(1)}% 随机:${procRand.toFixed(1)} -> ✘失败(技能「${skillConf.name}」命中 ${TAttrsComp.hero_name})`);
}
}
// 检查死亡
if (TAttrsComp.hp <= 0) {
// 先触发死亡技能(如亡语),不管后续是否续命都应触发
this.triggerDeadSkills(target);
// 死亡计数 +1(用于 dead 触发统计)
TAttrsComp.death_count++;
// 从 runtime_dead 中提取复活配置(revive_hp / revive_count)
let canRevive = false;
let maxReviveCount = 0;
let reviveHpPercent = 0;
let reviveSkillConf: any = null;
if (TAttrsComp.runtime_dead) {
for (const trig of TAttrsComp.runtime_dead) {
const reviveHp = trig.overrides?.revive_hp;
const reviveCount = trig.overrides?.revive_count ?? 0;
if (reviveHp !== undefined && reviveCount > 0) {
maxReviveCount = reviveCount;
if (TAttrsComp.revived_count < maxReviveCount) {
canRevive = true;
reviveHpPercent = reviveHp;
reviveSkillConf = SkillSet[trig.s_uuid];
}
break; // 只取第一个复活配置
}
}
}
if (canRevive) {
TAttrsComp.revived_count++;
TAttrsComp.is_reviving = true; // 标记为正在复活
// 根据 dead 触发配置恢复生命值
TAttrsComp.hp = Math.floor(TAttrsComp.hp_max * (reviveHpPercent / 100));
TAttrsComp.dirty_hp = true;
// 触发复活动画
if (targetView && reviveSkillConf) {
targetView.playReady(reviveSkillConf.readyAnm);
targetView.skill_name('', reviveSkillConf.uuid);
// 延迟 0.5 秒后恢复状态,让特效有时间播放,同时也能体现“正在复活”的过程
targetView.scheduleOnce(() => {
if (targetView.node && targetView.node.isValid) {
targetView.alive();
}
}, 0.5);
} else if (targetView) {
targetView.alive();
}
mLogger.log(this.debugMode, 'HeroAtkSystem', ` Hero waiting to revive! Lives revived: ${TAttrsComp.revived_count}/${maxReviveCount}, Hp restored: ${reviveHpPercent}%`);
return reDate;
}
this.doDead(target);
// ✅ 触发死亡视图表现
if (targetView) {
targetView.do_dead();
}
}
mLogger.log(this.debugMode, 'HeroAtkSystem', ` ${TAttrsComp.hero_name} 受到 ${damage} 点伤害 (暴击: ${isCrit})`);
reDate.damage = damage;
return reDate;
}
/**
* 详细伤害计算核心方法
*
* 计算流程:
* 1. 获取技能配置
* 2. 计算原始物理伤害
* 3. 应用最终伤害减免
* 4. 确保伤害值非负
*
* @param CAttrs 施法者属性快照对象,包含所有攻击力、属性加成等战斗属性
* @param TAttrs 目标属性对象,包含所有防御力、抗性等防御属性
* @param s_uuid 技能ID,用于获取技能配置信息和伤害类型
* @returns 经过完整计算后的最终伤害值(未考虑暴击)
*
* @important 注意事项:
*/
private dmgCount(damageEvent: DamageEvent, TAttrsComp: HeroAttrsComp) {
// 1. 获取技能配置 - 如果技能不存在,直接返回0伤害
const CAttrs = damageEvent.Attrs;
const TAttrs = TAttrsComp;
let sConf = SkillSet[damageEvent.s_uuid];
if (!sConf) return 0;
mLogger.log(this.debugMode, 'HeroAtkSystem', ` 伤害处理对象`, CAttrs, TAttrs);
// 2. 计算原始物理伤害和魔法伤害
// 物理伤害基础值 = 技能物理倍率 * (施法者物理攻击力 + 额外伤害) / 100 * 额外伤害比例
let apBase = (sConf.ap || 0) * (CAttrs[Attrs.ap] + damageEvent.ext_dmg) / 100 * damageEvent.dmg_ratio;
mLogger.log(this.debugMode, 'HeroAtkSystem', ` 物理伤害基础值: ${apBase}, 技能ap=${sConf.ap},施法者物理攻击力: ${CAttrs[Attrs.ap]},}
额外伤害:${damageEvent.ext_dmg}, 额外伤害比例:${damageEvent.dmg_ratio}`);
// 4. 确保伤害值非负
let total = Math.max(0, apBase);
if (this.debugMode) mLogger.log(this.debugMode, 'HeroAtkSystem', ` 最终伤害: ${total} (Base: ${apBase}`);
return total;
}
/**
* 触发死亡技能(如亡语)
* 不管是否复活,只要血量归零都触发
*/
private triggerDeadSkills(entity: ecs.Entity): void {
const TAttrsComp = entity.get(HeroAttrsComp);
if (!TAttrsComp) return;
const view = entity.get(HeroViewComp);
if (view) {
SkillTriggerHelper.trigger(SkillTriggerType.Dead, TAttrsComp, view);
// 【新增】仅英雄阵营派发全局死亡事件(怪物死亡会误触发海量卡牌效果)
if (TAttrsComp.fac === FacSet.HERO) {
oops.message.dispatchEvent(GameEvent.HeroDead, { eid: entity.eid });
}
}
}
/**
* 处理角色死亡
*
* 死亡处理流程:
* 1. 标记死亡状态(is_dead = true)
* 2. 英雄阵营:快照死亡状态到 smc.mission.dead_heroes(供后续复活机制重新召唤)
* 3. 触发死亡事件(onDeath)
*
* @param entity 死亡的实体
*
* @important 死亡状态一旦设置,该实体将不再处理新的伤害事件
* 英雄为真实死亡:视图动画结束后由 HeroViewComp.realDead 销毁实体
*/
private doDead(entity: ecs.Entity): void {
const TAttrsComp = entity.get(HeroAttrsComp);
if (!TAttrsComp || TAttrsComp.is_dead) return;
TAttrsComp.is_dead = true;
// 英雄真实死亡:记录快照,供后续复活机制(重新召唤)消费
if (TAttrsComp.fac === FacSet.HERO) {
this.recordDeadHero(TAttrsComp);
}
// 触发死亡事件
this.onDeath(entity);
if (this.debugMode) {
mLogger.log(this.debugMode, 'HeroAtkSystem', ` ${TAttrsComp.hero_name} 死亡`);
}
}
/**
* 快照死亡英雄的静态/成长数据到 smc.mission.dead_heroes。
* 仅记录重新召唤所需字段,不含战斗内临时状态(buff、技能 CD 等)。
*
* @param attrs 死亡英雄的属性组件
*/
private recordDeadHero(attrs: HeroAttrsComp): void {
smc.mission.dead_heroes.push({
uuid: attrs.hero_uuid,
lv: attrs.lv,
rarity: attrs.rarity,
posIndex: attrs.posIndex,
wave: Math.max(1, smc.vmdata?.mission_data?.level ?? 1),
});
mLogger.log(this.debugMode, 'HeroAtkSystem',
` 死亡英雄已记录: ${attrs.hero_name}(uuid=${attrs.hero_uuid}) lv=${attrs.lv} pos=${attrs.posIndex} wave=${smc.vmdata?.mission_data?.level}, 当前共 ${smc.mission.dead_heroes.length} 个`);
}
/**
* 统一概率判定方法
*
* 用于所有概率相关的判定:
* - 暴击率判定
* - 闪避率判定
* - 冰冻/击晕概率判定
* - 其他特殊效果概率
*
* @param rate 概率值(0-100的百分比)
* @returns true-判定成功,false-判定失败
*
* @example
* ```typescript
* // 10%概率触发
* if (this.checkChance(10)) {
* // 触发特殊效果
* }
* ```
*
* @important 概率为0或负数时直接返回false,避免不必要的随机数计算
*/
private checkChance(rate: number, label?: string): boolean {
if (rate <= 0) return false;
const capped = Math.min(rate, 70); // 70% 概率封顶
const r = Math.random() * 100;
const ok = r < capped;
if (label !== undefined) {
mLogger.log(this.debugMode, 'HeroAtkSystem', `[概率判定] ${label} 概率:${rate.toFixed(1)}%(封顶${capped}%) 随机:${r.toFixed(1)} -> ${ok ? "✔成功" : "✘失败"}`);
}
return ok;
}
/**
* 护盾数值减免伤害
*
* 护盾为数值减免型:
* 1. 1 点护盾吸收 1 点伤害
* 2. 护盾扣减实际吸收量,溢出部分继续结算到血量
*
* @param TAttrsComp 被攻击者的属性组件(包含当前护盾值)
* @param damage 原始伤害值
* @returns {remainingDamage, absorbedDamage} 剩余伤害值和被护盾减免的伤害值
*/
/**
* 目标出血层数附加的被暴击率(每层 +BLEED_CRIT_PER_STACK 百分点)。
* @returns 附加的被暴击率(百分点)
*/
private getBleedCritBonus(target: ecs.Entity): number {
if (!target.has(BuffComp)) return 0;
const layers = target.get(BuffComp).buffs.get(BLEED_BUFF_ID);
return (layers?.length ?? 0) * BLEED_CRIT_PER_STACK;
}
/**
* 闪避判定:闪避率 = 目标闪避 − 施法者命中,再按该值判定是否闪避。
* 命中未配置时(accuracy=0)闪避率 = 目标闪避,即无命中加成时闪避全额生效。
* @param damageEvent 伤害事件(含施法者属性快照)
* @param TAttrsComp 目标属性组件
* @returns true=闪避成功,false=命中
*/
private checkDodge(damageEvent: DamageEvent, TAttrsComp: HeroAttrsComp): boolean {
const dodgeChance = TAttrsComp.getFinalDodge() - (damageEvent.Attrs[Attrs.accuracy] || 0);
if (dodgeChance <= 0) return false;
return this.checkChance(dodgeChance, `闪避:${TAttrsComp.hero_name}`);
}
/**
* 计算易伤附加伤害:汇总目标每层易伤 buff 的数值(value_override 优先),再被易伤抗性百分比减免。
* @returns 附加伤害值(向下取整,非负)
*/
private calcVulnerableBonus(target: ecs.Entity, TAttrsComp: HeroAttrsComp): number {
if (!target.has(BuffComp)) return 0;
const layers = target.get(BuffComp).buffs.get(VULNERABLE_BUFF_ID);
if (!layers || layers.length === 0) return 0;
const cfg = BuffList[VULNERABLE_BUFF_ID];
const baseVal = cfg?.modifiers?.[0]?.value ?? 0;
let sum = 0;
for (const layer of layers) {
sum += layer.value_override !== undefined ? layer.value_override : baseVal;
}
if (sum <= 0) return 0;
// 易伤抗性:百分比减免(vulnerable_res=30 即减免 30%)
const res = TAttrsComp.getFinalVulnerableRes();
return Math.max(0, Math.floor(sum * (1 - res / 100)));
}
private absorbShield(TAttrsComp: HeroAttrsComp, damage: number): { remainingDamage: number, absorbedDamage: number } {
if (TAttrsComp.shield <= 0) {
return { remainingDamage: damage, absorbedDamage: 0 };
}
const absorbed = Math.min(TAttrsComp.shield, damage);
TAttrsComp.shield = Math.max(0, TAttrsComp.shield - absorbed);
TAttrsComp.dirty_shield = true;
return { remainingDamage: damage - absorbed, absorbedDamage: absorbed };
}
/**
* 对攻击者施加反伤伤害
*
* 反伤为固定数值伤害,直接扣减攻击者 HP,不走暴击/防御/护盾等常规结算
* (反伤通常是真实伤害或固定伤害,避免嵌套结算导致无限循环)
*
* @param casterEid 攻击者实体 ID
* @param thornsDmg 反伤伤害值
*/
private applyThornsDamage(casterEid: number, thornsDmg: number): void {
const caster = ecs.getEntityByEid(casterEid);
if (!caster) return;
const casterAttrs = caster.get(HeroAttrsComp);
if (!casterAttrs || casterAttrs.is_dead || casterAttrs.is_reviving) return;
// 反伤直接扣血,不经过护盾/防御结算(避免反伤-反伤无限循环)
casterAttrs.add_hp(-thornsDmg);
// 反伤计入评分统计(仅英雄阵营的反伤被统计,怪物反伤通常源于英雄技能/天赋)
if (casterAttrs.fac === FacSet.HERO) {
smc.vmdata.scores.thorns_dmg += thornsDmg;
}
mLogger.log(this.debugMode, 'HeroAtkSystem', ` 反伤: ${casterAttrs.hero_name} 受到 ${thornsDmg} 点反伤伤害`);
// 反伤致死检查
if (casterAttrs.hp <= 0) {
this.triggerDeadSkills(caster);
casterAttrs.death_count++;
this.doDead(caster);
const casterView = caster.get(HeroViewComp);
if (casterView) {
casterView.do_dead();
}
}
}
/**
* 解析 timed_buff_id 的施加目标实体列表。
* 缺省(undefined / BuffTarget.Self)返回施法者自身(buff 为增益语义,如吸血/攻击后强化);
* 显式配置可切换到被命中者、全体友方或全体敌方。
* @param hitTarget 被命中实体(伤害事件的 target)
* @param casterEid 施法者 eid
* @param targetFac 被命中者阵营(用于推导敌我网格)
* @param buffTarget 技能配置上的目标覆写
*/
private resolveBuffTargets(hitTarget: ecs.Entity, casterEid: number, targetFac: number, buffTarget?: BuffTarget): ecs.Entity[] {
if (buffTarget === undefined || buffTarget === BuffTarget.Self) {
const caster = ecs.getEntityByEid(casterEid);
return caster ? [caster] : [];
}
if (buffTarget === BuffTarget.Hit) {
return [hitTarget];
}
// 全体友方/敌方:以施法者阵营为基准(命中者必为敌方,施法方网格即友方)
const casterFac = targetFac === FacSet.HERO ? FacSet.MON : FacSet.HERO;
const grid = buffTarget === BuffTarget.Ally
? (casterFac === FacSet.HERO ? smc.mission.heroGrid : smc.mission.monGrid)
: (casterFac === FacSet.HERO ? smc.mission.monGrid : smc.mission.heroGrid);
const targets: ecs.Entity[] = [];
for (const eid of grid) {
if (eid < 0) continue;
const ent = ecs.getEntityByEid(eid);
if (!ent) continue;
const attrs = ent.get(HeroAttrsComp);
if (attrs && !attrs.is_dead && !attrs.is_reviving) targets.push(ent);
}
return targets;
}
/**
* 被攻击时触发的事件
*
* 当前已实现的逻辑:
* - 反伤效果:目标通过 thorns_dmg 属性对攻击者造成固定反伤(在 doAttack 中结算)
*
* 预留的扩展点:
* - 触发被攻击天赋(如受击回血、受击反击等)
* - 触发特殊状态(如受伤狂暴、受伤护盾等)
*
* @param entity 被攻击的实体
*
* @todo 当前对怪物实体直接返回,后续可以根据需求扩展怪物的被攻击逻辑
*/
private onAttacked(entity: ecs.Entity): void {
const TAttrsComp = entity.get(HeroAttrsComp);
if (!TAttrsComp || TAttrsComp.is_dead) return;
TAttrsComp.atked_count++;
// 这里可以添加被攻击时的特殊处理逻辑
if (TAttrsComp.fac === FacSet.MON) return;
// 例如:触发某些天赋效果、反击逻辑等
}
/**
* 死亡时触发的事件
*
* 根据实体阵营类型处理不同的死亡逻辑:
*
* - FacSet.MON(怪物):触发掉落逻辑
* - 延迟执行掉落,避免阻塞主逻辑
* - 可以扩展:经验值计算、任务进度等
*
* - FacSet.HERO(英雄):触发英雄死亡特殊处理
* - 游戏结束判定
* - 复活机制检查
* - 死亡惩罚/奖励
*
* @param entity 死亡的实体
*
* @important 死亡事件应该幂等,避免重复触发
*/
private onDeath(entity: ecs.Entity): void {
const TAttrsComp = entity.get(HeroAttrsComp);
if (!TAttrsComp) return;
if (TAttrsComp.fac === FacSet.MON) {
// 怪物死亡处理
this.scheduleDrop(entity);
// oops.message 全局事件总线:怪物死亡(连杀计数等表现层消费)
oops.message.dispatchEvent(GameEvent.MonDead, {
isBoss: !!TAttrsComp.is_boss,
worldPos: entity.get(HeroViewComp)?.node?.worldPosition?.clone() ?? null,
});
} else if (TAttrsComp.fac === FacSet.HERO) {
// 英雄死亡处理
this.scheduleHeroDeath(entity);
}
}
/**
* 怪物死亡掉落金币
*
* 实现说明:
* - 通过 hero_uuid 查 HeroInfo 获取 monType
* - 按怪物类型查 MonsterGoldSet 获取固定金币掉落数(Boss 有独立的高额奖励)
* - 调用 MissionEconomy.addCoin 统一发放金币(会自动触发 UI 更新和评分统计)
*
* @param entity 死亡的怪物实体
*/
private scheduleDrop(entity: ecs.Entity): void {
const TAttrsComp = entity.get(HeroAttrsComp);
if (!TAttrsComp) return;
// 通过 hero_uuid 查配置获取怪物类型
const info = HeroInfo[TAttrsComp.hero_uuid];
const monType = info?.monType ?? 0;
const gold = getMonsterGoldDrop(monType, !!TAttrsComp.is_boss);
if (gold <= 0) return;
MissionEconomy.addCoin(gold);
// oops.message 全局事件总线:金币飞行表现(账务已即时结算,飞行纯视觉)
oops.message.dispatchEvent(GameEvent.CoinFly, {
worldPos: entity.get(HeroViewComp)?.node?.worldPosition?.clone() ?? null,
gold,
isBoss: !!TAttrsComp.is_boss,
});
mLogger.log(this.debugMode, 'HeroAtkSystem',
` ${TAttrsComp.hero_name} 死亡掉落金币 ${gold}(monType=${monType}, isBoss=${TAttrsComp.is_boss})`);
}
/**
* 延迟执行英雄死亡逻辑
*
* 英雄死亡的特殊处理,比普通怪物复杂:
*
* 处理内容包括:
* - 检查复活次数和复活条件
* - 触发游戏结束界面(如适用)
* - 记录死亡统计信息
* - 处理死亡惩罚(经验损失、装备损坏等)
*
* @param entity 死亡的英雄实体
*
* @important 英雄死亡通常需要玩家交互,所以必须延迟处理
* 给玩家足够的反馈时间和操作空间
*/
private scheduleHeroDeath(entity: ecs.Entity): void {
// 这里可以添加英雄死亡的特殊处理
// 例如:触发游戏结束、复活机制等
}
/**
* 启用调试模式
*/
enableDebug() {
this.debugMode = true;
}
/**
* 禁用调试模式
*/
disableDebug() {
this.debugMode = false;
}
}