import type { World, EntityId } from 'db://assets/mx/module/ecs'; import type { AbilityContext } from 'db://assets/mx/module/battle/combat/AbilityContext'; import { AttrId, TagComp, HealthComp, AttributeComp, AtkHookComp, TransformComp, MovementComp, canDealDamageToYanClaimed, isBajiaoBlowRetreating, type AttributeModifier, } from 'db://assets/mx/module/battle/ecs/Components'; import { BattleEventFactory } from 'db://assets/mx/module/battle/events/BattleEvents'; import type { BattleEventBus } from 'db://assets/mx/module/battle/events/BattleEventBus'; import type { AttributeSystem } from './AttributeSystem'; import type { LifecycleSystem } from './LifecycleSystem'; import type { EffectExecutor } from './EffectExecutor'; import type { GridMap } from '../battlefield/GridMap'; import { traceCombatDamage } from 'db://assets/mx/tools/CombatDamageTrace'; /** ctx.vars 中标记本次伤害是否暴击的键 */ export const VAR_LAST_CRIT = '__lastCrit'; /** ctx.vars 中记录本次 dealToTarget 实际伤害量(供 atk_after 击退等判定) */ export const VAR_LAST_DAMAGE_AMOUNT = '__lastDamageAmount'; /** 同次命中累计伤害(含 atk_after 内追加扣血),供帧末统一飘字 */ export const VAR_STRIKE_DISPLAY_DAMAGE = '__strikeDisplayDamage'; /** 单次命中临时 modifier group(命中后立即清掉,避免污染下一目标) */ export const HIT_TEMP_GROUP = '__hit_temp'; /** 次数型正面 buff modifier group(跨多次普攻,每次 dealToTarget 扣 1 次) */ export const COUNT_BUFF_GROUP = 'entry_count_buff'; /** 伤害结算调试(业务层可挂刀等武器验数) */ export interface DamageCalcDebugInfo { caster: EntityId; abilityId: string; baseAtk: number; scale: number; afterScale: number; dmgMul: number; distMul: number; isCrit: boolean; critDamage: number; amount: number; damageType?: string; } let _damageCalcDebug: ((info: DamageCalcDebugInfo) => void) | null = null; export function setDamageCalcDebug(fn: ((info: DamageCalcDebugInfo) => void) | null): void { _damageCalcDebug = fn; } interface StrikeHitAfterMeta { isCrit: boolean; damageType?: string; } /** 伤害计算与应用(每目标独立:AttackBefore → calc → 扣血 → atk_after → HitAfter 飘字) */ export class DamagePipeline { private _dealDepth = 0; private _strikeHitAfterMeta: StrikeHitAfterMeta | null = null; /** 根 dealToTarget 结束时统一击杀,保证 HitAfter/血条同步先于 EntityKilled */ private _pendingKills: Array<{ victim: EntityId; killer: EntityId; hpBefore: number; damageType?: string; fromPassiveResult?: boolean }> = []; constructor( private readonly _world: World, private readonly _attributes: AttributeSystem, private readonly _events: BattleEventBus, private readonly _lifecycle: LifecycleSystem, private readonly _effects?: EffectExecutor, private readonly _grid?: GridMap, ) {} /** * 对单个目标完整走一遍命中管线(暴击/before/after 均按该怪独立)。 * @returns 实际造成的伤害量;目标无效返回 0 */ dealToTarget( caster: EntityId, target: EntityId, params: Record, ctx: AbilityContext, ): number { if (!this._world.isAlive(caster) || !this._world.isAlive(target)) { traceCombatDamage('dealSkip_notAlive', { caster, target }); return 0; } const targetMove = this._world.getComponent(target, MovementComp); // 出生跳出保护:不可被攻击命中 if (targetMove?.spawnProtected) { traceCombatDamage('dealSkip_spawnProtected', { caster, target }); return 0; } // 芭蕉扇吹退中:暂时不可被命中 if (isBajiaoBlowRetreating(targetMove)) { traceCombatDamage('dealSkip_bajiaoRetreat', { caster, target }); return 0; } // 阎王伸爪/囚禁:仅囚禁施法者 yan_cage_dot 可伤 if (!canDealDamageToYanClaimed(targetMove, caster, params.damageType as string | undefined)) { traceCombatDamage('dealSkip_yanClaimed', { caster, target, damageType: params.damageType, }); return 0; } const dmgType = typeof params.damageType === 'string' ? params.damageType : ''; const fromPassiveResult = params.fromPassiveResult === true || dmgType.indexOf('passive_') === 0; this._dealDepth++; const isRootDeal = this._dealDepth === 1; try { if (isRootDeal) { ctx.vars.delete(VAR_STRIKE_DISPLAY_DAMAGE); this._strikeHitAfterMeta = null; this._pendingKills.length = 0; } const prevTargets = ctx.targets; ctx.targets = [target]; ctx.vars.delete('__distDmgMul'); ctx.vars.delete('__extraDmgMul'); this._attributes.removeModifiersByGroup(caster, HIT_TEMP_GROUP); const abilityId = ctx.abilityId; if (!fromPassiveResult) { this._events.emit(BattleEventFactory.attackBefore(caster, [target], abilityId)); } // __bladeLightScale 跨本段多目标保留:任一怪触发 atk_after 刀光后供表现层读一次 ctx.vars.delete('__dmgAreaR'); ctx.vars.delete('__dmgAreaScale'); ctx.vars.delete('__dmgAreaInterval'); ctx.vars.delete('__dmgAreaDur'); const skipEntryHooks = fromPassiveResult || dmgType === 'blade_light' || dmgType === 'burn_dot' || dmgType === 'bleed_dot'; this._runBeforeHooks(ctx, params.atkBefore as string | undefined, skipEntryHooks); this._applyDistDmgBonus(ctx, params, skipEntryHooks); let amount = this.calc(ctx, params); const flatBonus = ctx.vars.get('__flatBonusDamage'); if (flatBonus != null && flatBonus > 0) { amount += Math.floor(flatBonus); } ctx.vars.delete('__flatBonusDamage'); if (amount > 0) { this.apply(caster, target, amount, params.damageType as string | undefined, ctx, false, fromPassiveResult); } else { traceCombatDamage('dealCalcZero', { caster, target, abilityId: ctx.abilityId, damageType: params.damageType, }); } ctx.vars.set(VAR_LAST_DAMAGE_AMOUNT, amount); this._runSkillAtkBuffs(ctx, params, skipEntryHooks); this._runAfterHooks(ctx, params.atkAfter as string | undefined, skipEntryHooks); ctx.vars.delete(VAR_LAST_DAMAGE_AMOUNT); this._consumeCountBuffCharges(caster, ctx); this._attributes.removeModifiersByGroup(caster, HIT_TEMP_GROUP); ctx.targets = prevTargets; const strikeTotal = ctx.vars.get(VAR_STRIKE_DISPLAY_DAMAGE) ?? 0; if (isRootDeal) { traceCombatDamage('dealRootEnd', { caster, target, abilityId: ctx.abilityId, strikeTotal, pendingKills: this._pendingKills.length, }); this._events.flush(); if (strikeTotal > 0) { this._emitStrikeHitAfter(caster, target, abilityId, ctx, strikeTotal, true); } this._flushPendingKills(); if (fromPassiveResult) { this._events.flush(); } ctx.vars.delete(VAR_STRIKE_DISPLAY_DAMAGE); this._strikeHitAfterMeta = null; } return isRootDeal ? strikeTotal : amount; } finally { this._dealDepth--; } } /** * @param ctx 技能上下文(targets 应为当前单怪) * @param params 伤害参数(scale、bonusVsTag 等) * @returns 最终伤害值(向下取整) */ calc(ctx: AbilityContext, params: Record): number { const percentMaxHpWan = params.percentMaxHpWan as number | undefined; if (percentMaxHpWan != null && percentMaxHpWan > 0 && ctx.targets[0] != null) { const hp = this._world.getComponent(ctx.targets[0], HealthComp); const amount = Math.max(0, Math.floor((hp?.maxHp ?? 0) * percentMaxHpWan / 10000)); ctx.vars.set(VAR_LAST_CRIT, 0); return amount; } const scale = (params.scale as number) ?? 1; const baseAtk = this._attributes.getAttr(ctx.caster, AttrId.Attack); let atk = baseAtk * scale; const afterScale = atk; let dmgMul = 1; const attr = this._world.getComponent(ctx.caster, AttributeComp); if (attr) { // dmg_mul:配表值即倍数(默认 1);多个 Add 取乘积语义 → 1 + sum(value-1) 若存的是倍数-1 // SkillPickApplier 存 value-1;此处还原为倍数 for (const m of attr.modifiers) { if (m.attr === 'dmg_mul' && m.op === 'Add') dmgMul += m.value; } if (dmgMul !== 1) { atk *= dmgMul; ctx.vars.set('__extraDmgMul', 1); } // 受击增伤标记(目标身上) const targetAttr = this._world.getComponent(ctx.targets[0], AttributeComp); if (targetAttr) { for (const m of targetAttr.modifiers) { if (m.source === 'dmg_mark' && m.attr === 'dmg_taken_mul' && m.op === 'Mul') { atk *= m.value; } } } } // 感电等非标记受击乘区(独立于 dmg_mark) const elecTarget = ctx.targets[0]; if (elecTarget != null) { const elecAttr = this._world.getComponent(elecTarget, AttributeComp); if (elecAttr) { for (const m of elecAttr.modifiers) { if (m.source !== 'dmg_mark' && m.attr === 'dmg_taken_mul' && m.op === 'Mul') { atk *= m.value; } } } } let distMul = 1; const distMulVar = ctx.vars.get('__distDmgMul'); if (distMulVar != null && distMulVar > 0) { distMul = distMulVar; atk *= distMul; } const bonusVsTag = params.bonusVsTag as string | undefined; if (bonusVsTag && ctx.targets.length > 0) { const tags = this._world.getComponent(ctx.targets[0], TagComp); if (tags?.tags.includes(bonusVsTag)) { atk *= (params.bonusMul as number) ?? 1.3; } } let isCrit = false; let critDamage = this._attributes.getAttr(ctx.caster, AttrId.CritDamage); if (this._canCrit(ctx, params)) { const critRate = Math.min(1, this._attributes.getAttr(ctx.caster, AttrId.CritRate)); if (ctx.rng.next() < critRate) { isCrit = true; atk *= critDamage; } } ctx.vars.set(VAR_LAST_CRIT, isCrit ? 1 : 0); let amount = Math.max(0, Math.floor(atk)); const additivePercentWan = params.additivePercentMaxHpWan as number | undefined; if (additivePercentWan != null && additivePercentWan > 0 && ctx.targets[0] != null) { const hp = this._world.getComponent(ctx.targets[0], HealthComp); amount += Math.max(0, Math.floor((hp?.maxHp ?? 0) * additivePercentWan / 10000)); } if (_damageCalcDebug) { _damageCalcDebug({ caster: ctx.caster, abilityId: ctx.abilityId, baseAtk, scale, afterScale, dmgMul, distMul, isCrit, critDamage, amount, damageType: params.damageType as string | undefined, }); } return amount; } /** 普攻可暴击;大招默认不可,除非 params.allowCrit / AtkHookComp.ultCanCrit */ private _canCrit(ctx: AbilityContext, params: Record): boolean { if (params.allowCrit === true) return true; if (params.allowCrit === false) return false; const isUlt = ctx.abilityId.includes('_ult_'); if (!isUlt) return true; const hook = this._world.getComponent(ctx.caster, AtkHookComp); if (hook?.ultCanCrit) return true; const attr = this._world.getComponent(ctx.caster, AttributeComp); return !!attr?.modifiers.some(m => m.attr === 'ult_can_crit' && (m.remain == null || m.remain > 0)); } /** * @param caster 伤害来源实体 ID * @param target 目标实体 ID * @param amount 伤害量 * @param damageType 伤害类型 * @param ctx 可选技能上下文 * @param emitBefore 是否派发 AttackBefore(dealToTarget 已派发时传 false) * @param fromPassiveResult 是否来自被动 result */ apply( caster: EntityId, target: EntityId, amount: number, damageType?: string, ctx?: AbilityContext, emitBefore = true, fromPassiveResult = false, ): void { const health = this._world.getComponent(target, HealthComp); if (!health) { traceCombatDamage('applyNoHealthComp', { caster, target, amount }); return; } if (!Number.isFinite(health.hp) || !Number.isFinite(health.maxHp)) { traceCombatDamage('applyInvalidHealth', { caster, target, amount, hp: health.hp, maxHp: health.maxHp, }); return; } const targetMove = this._world.getComponent(target, MovementComp); if (targetMove?.spawnProtected) { traceCombatDamage('applySkip_spawnProtected', { caster, target, amount }); return; } if (isBajiaoBlowRetreating(targetMove)) { traceCombatDamage('applySkip_bajiaoRetreat', { caster, target, amount }); return; } if (!canDealDamageToYanClaimed(targetMove, caster, damageType)) { traceCombatDamage('applySkip_yanClaimed', { caster, target, amount, damageType }); return; } const hpBefore = health.hp; const abilityId = ctx?.abilityId; if (ctx && emitBefore) { this._events.emit(BattleEventFactory.attackBefore(caster, [target], abilityId)); } health.hp = Math.max(0, health.hp - amount); const resolvedType = damageType ?? (ctx?.vars.get('__extraDmgMul') === 1 ? 'extra_mul' : undefined); this._events.emit(BattleEventFactory.damageDealt(caster, target, amount, resolvedType, fromPassiveResult), fromPassiveResult); if (ctx) { this._accumulateStrikeDisplayDamage(ctx, amount, resolvedType); } if (health.hp <= 0) { this._pendingKills.push({ victim: target, killer: caster, hpBefore, damageType: resolvedType, fromPassiveResult }); } traceCombatDamage('applyOk', { caster, target, amount, damageType, hpBefore, hpAfter: health.hp, maxHp: health.maxHp, willKill: health.hp <= 0, hasHealthComp: true, }); } private _flushPendingKills(): void { for (const { victim, killer, hpBefore, damageType, fromPassiveResult } of this._pendingKills) { traceCombatDamage('killFlush', { victim, killer, hpBefore, alive: this._world.isAlive(victim), }); if (this._world.isAlive(victim)) { this._lifecycle.kill(victim, killer, hpBefore, damageType, fromPassiveResult); } } this._pendingKills.length = 0; } /** 同次命中内累计展示伤害;HitAfter 在 dealToTarget 根调用结束时统一派发 */ private _accumulateStrikeDisplayDamage( ctx: AbilityContext, amount: number, damageType?: string, ): void { if (amount <= 0) return; const prev = ctx.vars.get(VAR_STRIKE_DISPLAY_DAMAGE) ?? 0; ctx.vars.set(VAR_STRIKE_DISPLAY_DAMAGE, prev + amount); if (!this._strikeHitAfterMeta) { this._strikeHitAfterMeta = { isCrit: ctx.vars.get(VAR_LAST_CRIT) === 1, damageType, }; } } private _emitStrikeHitAfter( caster: EntityId, target: EntityId, abilityId: string, ctx: AbilityContext, displayAmount: number, immediate = false, ): void { if (displayAmount <= 0) return; const meta = this._strikeHitAfterMeta; this._events.emit(BattleEventFactory.hitAfter( caster, target, displayAmount, meta?.isCrit ?? ctx.vars.get(VAR_LAST_CRIT) === 1, abilityId, meta?.damageType, ), immediate); } /** * Skill.skill 类型8:每 distPerPx 像素伤害 +distAddWan(万分比),封顶 distMaxWan。 * @param ctx 技能上下文 * @param params DealDamage 参数 * @param skipEntryHooks 跳过 DoT 等非普攻 */ private _applyDistDmgBonus(ctx: AbilityContext, params: Record, skipEntryHooks: boolean): void { if (skipEntryHooks) return; const target = ctx.targets[0]; if (target == null) return; const hook = this._world.getComponent(ctx.caster, AtkHookComp); if (hook) { this._accumDistMul(ctx, target, hook.distPerPx, hook.distAddWan, hook.distMaxWan); } const parts = params.skillParts as number[] | undefined; if ((params.skillType as number) === 8 && parts) { const perPx = parts[1]; const addWan = parts[2]; const maxWan = parts[3]; if (!(perPx > 0) || !(addWan > 0) || !(maxWan > 0)) { console.log('[DamagePipeline] skill 类型8参数无效', parts.join(',')); return; } this._accumDistMul(ctx, target, perPx, addWan, maxWan); } } /** * @param ctx 技能上下文 * @param target 当前命中目标 * @param perPx 距离系数(像素) * @param addWan 每段伤害加值(万分比) * @param maxWan 最大加值(万分比) */ private _accumDistMul(ctx: AbilityContext, target: EntityId, perPx: number, addWan: number, maxWan: number): void { if (!(perPx > 0) || !(addWan > 0) || !(maxWan > 0)) return; const distPx = this.distancePx(ctx.caster, target); const add = Math.min(maxWan, Math.floor(distPx / perPx) * addWan); if (!(add > 0)) return; const prev = ctx.vars.get('__distDmgMul') ?? 1; ctx.vars.set('__distDmgMul', prev * (1 + add / 10000)); } /** 逻辑世界像素距离(供 atk_before 距离加伤;配表距离为像素) */ distancePx(a: EntityId, b: EntityId): number { const pa = this._entityWorldPos(a); const pb = this._entityWorldPos(b); if (!pa || !pb) return 0; const dx = pa.x - pb.x; const dy = pa.y - pb.y; return Math.sqrt(dx * dx + dy * dy); } /** @deprecated 使用 distancePx;保留格距供旧调用 */ distanceCells(a: EntityId, b: EntityId): number { if (!this._grid) return 0; const ta = this._world.getComponent(a, TransformComp); const tb = this._world.getComponent(b, TransformComp); if (!ta || !tb) return 0; return this._grid.distanceCells(ta, tb); } private _entityWorldPos(id: EntityId): { x: number; y: number } | null { const move = this._world.getComponent(id, MovementComp); if (move && (move.worldX !== 0 || move.worldY !== 0)) { return { x: move.worldX, y: move.worldY }; } if (!this._grid) return null; const tf = this._world.getComponent(id, TransformComp); if (!tf) return null; return this._grid.cellToWorld({ row: tf.row, col: tf.col }); } private _runBeforeHooks( ctx: AbilityContext, fromParams?: string, skipEntryHooks = false, ): void { if (!this._effects) return; for (const raw of this._collectHooks(ctx, 'before', fromParams, skipEntryHooks)) { this._effects.execute(ctx, { type: 'RunAtkBefore', params: { raw } }); } } private _runAfterHooks( ctx: AbilityContext, fromParams?: string, skipEntryHooks = false, ): void { if (!this._effects) return; for (const raw of this._collectHooks(ctx, 'after', fromParams, skipEntryHooks)) { this._effects.execute(ctx, { type: 'RunAtkAfter', params: { raw } }); } } /** Skill.atk_buff1/2(与词条 atk_before/after 分离) */ private _runSkillAtkBuffs( ctx: AbilityContext, params: Record, skipEntryHooks: boolean, ): void { if (skipEntryHooks || !this._effects) return; const buff1 = String(params.skillAtkBuff1 ?? '').trim(); const buff2 = String(params.skillAtkBuff2 ?? '').trim(); if (!buff1 && !buff2) return; const target = ctx.targets[0]; if (target == null) return; const lastDamage = ctx.vars.get(VAR_LAST_DAMAGE_AMOUNT) ?? 0; this._effects.execute(ctx, { type: 'ApplySkillAtkBuff', params: { buff1, buff2, lastDamage }, }); } private _collectHooks( ctx: AbilityContext, kind: 'before' | 'after', fromParams?: string, skipEntryHooks = false, ): string[] { const out: string[] = []; if (fromParams?.trim()) out.push(fromParams.trim()); if (skipEntryHooks) return out; const hook = this._world.getComponent(ctx.caster, AtkHookComp); if (!hook) return out; const isUlt = ctx.abilityId.includes('_ult_'); const arr = isUlt ? (kind === 'before' ? hook.ultBefore : hook.ultAfter) : (kind === 'before' ? hook.before : hook.after); for (const s of arr) { if (s?.trim()) out.push(s.trim()); } return out; } /** 次数型正面 buff:一次施法内多目标只扣 1 次 */ private _consumeCountBuffCharges(caster: EntityId, ctx: AbilityContext): void { const castId = ctx.vars.get('__castInstanceId'); if (castId != null) { const key = `__countBuffUsed_${castId}`; if (ctx.vars.get(key) === 1) return; ctx.vars.set(key, 1); } const attr = this._world.getComponent(caster, AttributeComp); if (!attr) return; const remain: AttributeModifier[] = []; for (const m of attr.modifiers) { if (m.group === COUNT_BUFF_GROUP && m.charges != null) { const next = m.charges - 1; if (next > 0) remain.push({ ...m, charges: next }); continue; } remain.push(m); } attr.modifiers = remain; } }