You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
AdLike2048/assets/mx/module/battle/combat/DamagePipeline.ts

642 lines
24 KiB

import type { World, EntityId } from 'db://assets/mx/module/ecs';
import type { AbilityContext } from './AbilityContext';
import {
AttrId,
TagComp,
HealthComp,
AttributeComp,
AtkHookComp,
TransformComp,
MovementComp,
canDealDamageToYanClaimed,
isBajiaoBlowRetreating,
type AttributeModifier,
} from '../ecs/Components';
import { BattleEventFactory } from '../events/BattleEvents';
import type { BattleEventBus } from '../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;
}
/** 业务层伤害预调(必暴/技能乘区等);在暴击判定前调用 */
export interface DamagePreCalcState {
/** 当前攻击力(已含 scale/dmg_mul) */
atk: number;
/** 是否强制暴击 */
forceCrit: boolean;
/** 暴击伤害倍率(可追加) */
critDamage: number;
/** 是否把当前暴击率并入爆伤并强制满暴 */
critRateToCritDmg: boolean;
}
export type DamagePreCalcHook = (
ctx: AbilityContext,
params: Record<string, unknown>,
state: DamagePreCalcState,
) => void;
let _damagePreCalc: DamagePreCalcHook | null = null;
/** 注册/清除业务伤害预调 */
export function setDamagePreCalcHook(fn: DamagePreCalcHook | null): void {
_damagePreCalc = 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<string, unknown>,
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<string, unknown>): 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);
const pre: DamagePreCalcState = {
atk,
forceCrit: false,
critDamage,
critRateToCritDmg: false,
};
if (_damagePreCalc) _damagePreCalc(ctx, params, pre);
atk = pre.atk;
critDamage = pre.critDamage;
if (pre.critRateToCritDmg) {
const cr = Math.min(1, this._attributes.getAttr(ctx.caster, AttrId.CritRate));
critDamage += cr * 2;
pre.forceCrit = true;
}
if (this._canCrit(ctx, params)) {
const critRate = Math.min(1, this._attributes.getAttr(ctx.caster, AttrId.CritRate));
if (pre.forceCrit || ctx.rng.next() < critRate) {
isCrit = true;
atk *= critDamage;
}
}
ctx.vars.set(VAR_LAST_CRIT, isCrit ? 1 : 0);
// atk=1 × 系数<1 时 floor 会变 0(木1/水1/火1/土1);有正伤害贡献时至少 1
let amount = Math.max(0, Math.floor(atk));
if (amount === 0 && atk > 0) amount = 1;
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<string, unknown>): 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<string, unknown>, 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<string, unknown>,
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;
}
}