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.
640 lines
24 KiB
640 lines
24 KiB
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;
|
|
}
|
|
|
|
/** 业务层伤害预调(必暴/技能乘区等);在暴击判定前调用 */
|
|
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);
|
|
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<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;
|
|
}
|
|
}
|
|
|