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/script/battle/combat/AtkHookEffects.ts

948 lines
33 KiB

3 weeks ago
import type { AbilityContext } from 'db://assets/mx/module/battle/combat/AbilityContext';
import { createMembershipSet, membershipAdd, membershipFromArray, membershipDedupeArray, type MembershipSet, type ReadonlyMembershipSet } from '../../../mx/tools/MembershipSet';
import type { BattleServices } from 'db://assets/mx/module/battle/combat/BattleServices';
import { HIT_TEMP_GROUP, VAR_LAST_CRIT, VAR_LAST_DAMAGE_AMOUNT, COUNT_BUFF_GROUP } from 'db://assets/mx/module/battle/combat/DamagePipeline';
import { ATK_FAST_ASPD_GROUP, TIMED_BUFF_GROUP } from './EntryBuffGroups';
import {
AttrId,
AttributeComp,
CombatComp,
ComboAttackComp,
HealthComp,
MovementComp,
TagComp,
TransformComp,
UnitMetaComp,
} from 'db://assets/mx/module/battle/ecs/Components';
import { EntityTag } from 'db://assets/mx/module/battle/ecs/EntityTag';
import { BattleEventFactory } from 'db://assets/mx/module/battle/events/BattleEvents';
import { EffectRegistry } from 'db://assets/mx/module/battle/registry/EffectRegistry';
import { TableNames } from '../../game/ConfigTableData';
import { parseLegacyMonsterId, parseMonsterUnitDefId } from '../config/MonsterHpCalculator';
const ATTR_BY_CODE: Record<number, string> = {
1: AttrId.Attack,
2: 'aspd_add',
3: AttrId.CritRate,
4: AttrId.CritDamage,
5: 'cd_add',
6: 'dmg_mul',
};
/** 次数型词条 buff(跨多次普攻,由 DamagePipeline 按次扣减) */
export { COUNT_BUFF_GROUP } from 'db://assets/mx/module/battle/combat/DamagePipeline';
/** 计时型 / 攻速 buff 分组常量 */
export { ATK_FAST_ASPD_GROUP, TIMED_BUFF_GROUP } from './EntryBuffGroups';
/** 永久叠加上限型词条 buff */
export const PERM_BUFF_GROUP = 'entry_perm_buff';
/** 不可击退的怪物类型:4~6宝箱怪 7工厂 8副本宝珠(BOSS 可击退) */
const KNOCKBACK_IMMUNE_MONSTER_TYPES = membershipFromArray([4, 5, 6, 7, 8]);
const KNOCKBACK_APPLIED_EVENT = 'KnockbackApplied';
const ATK_HOOK_FLAG = '__mx_battle_atkHookEffectsRegistered__';
/** 注册 atk_before / atk_after 业务 effect(每怪命中管线调用) */
export function registerAtkHookEffects(): void {
const g = globalThis as unknown as Record<string, boolean | undefined>;
if (g[ATK_HOOK_FLAG]) return;
g[ATK_HOOK_FLAG] = true;
EffectRegistry.register('RunAtkBefore', (services, ctx, params) => {
runAtkBefore(services, ctx, String(params.raw ?? ''));
});
EffectRegistry.register('RunAtkAfter', (services, ctx, params) => {
runAtkAfter(services, ctx, String(params.raw ?? ''));
});
}
/** 解析「;」分隔的多段编码,每段首位为子类型 */
function splitSegments(raw: string): number[][] {
const out: number[][] = [];
for (const seg of raw.split(';')) {
const part = seg.trim();
if (!part) continue;
const nums = part.split(',').map(Number);
if (!nums.length || !Number.isFinite(nums[0])) continue;
out.push(nums);
}
return out;
}
function roll(ctx: AbilityContext, chanceWan: number): boolean {
return ctx.rng.next() < Math.max(0, chanceWan) / 10000;
}
/** 同一次施法内 before 段只判定/执行一次(扇形多目标、刀光碰撞不重复触发) */
function claimCastOnce(ctx: AbilityContext, key: string): boolean {
const castId = ctx.vars.get('__castInstanceId');
if (castId == null) return true;
const flag = `__once_${key}`;
if (ctx.vars.get(flag) === castId) return false;
ctx.vars.set(flag, castId);
return true;
}
/**
* atk_before:命中前、按当前单怪执行。
* 1 概率最快攻速 / 2 怪状态加属性 / 3 概率加属性 / 4 距离加伤
*/
export function runAtkBefore(services: BattleServices, ctx: AbilityContext, raw: string): void {
if (!raw) return;
const target = ctx.targets[0];
if (target == null) return;
for (const p of splitSegments(raw)) {
const kind = p[0];
switch (kind) {
case 1: {
// 1,概率,持续时间类型,持续参数
// 次数型(1):排队连击,表现 finish 后 AbilitySystem 再施放普攻
// 时间型(2):在 durParam 秒内强制最快攻速
if (ctx.vars.get('__comboAttack') === 1) break;
if (!claimCastOnce(ctx, 'atkBefore1')) break;
if (!roll(ctx, p[1] ?? 0)) break;
const durType = p[2] ?? 1;
const durParam = Math.max(1, p[3] ?? 1);
if (durType === 2) {
applyMaxAspdModifier(services, ctx.caster, durParam, ATK_FAST_ASPD_GROUP);
} else {
queueComboAttacks(services, ctx.caster, target, durParam);
}
break;
}
case 2: {
// 2,怪物状态,属性,值 — 该怪持有状态时本次加属性(仅当次命中)
const stateId = p[1] ?? 0;
const attrCode = p[2] ?? 1;
const value = p[3] ?? 0;
if (!monsterHasState(services, target, stateId)) break;
applyHitTempAttr(services, ctx.caster, attrCode, value);
break;
}
case 3: {
// 3,概率,属性,值,buff类型,类型参数
// buff类型 1=临时(次数) 2=计时(秒) 3=永久(叠加上限);仅本子类型使用
const chanceWan = p[1] ?? 0;
const castId = ctx.vars.get('__castInstanceId');
const decidedKey = castId != null ? `__atkBefore3_ok_${castId}` : null;
let passed: boolean;
if (decidedKey) {
const cached = ctx.vars.get(decidedKey);
if (cached == null) {
passed = roll(ctx, chanceWan);
ctx.vars.set(decidedKey, passed ? 1 : 0);
} else {
passed = cached === 1;
}
} else {
passed = roll(ctx, chanceWan);
}
if (!passed) break;
applyProbAttrBuff(
services,
ctx.caster,
p[2] ?? 1,
p[3] ?? 0,
p[4] ?? 1,
p[5] ?? 1,
true,
);
break;
}
case 4: {
// 4,每X像素距离增加10000,最大Y
const perDist = p[1] ?? 0;
const maxWan = p[2] ?? 0;
if (perDist <= 0) break;
const distPx = services.damage.distancePx(ctx.caster, target);
const addWan = Math.min(maxWan, Math.floor(distPx / perDist) * 10000);
if (addWan > 0) {
const mul = 1 + addWan / 10000;
const prev = ctx.vars.get('__distDmgMul') ?? 1;
ctx.vars.set('__distDmgMul', prev * mul);
}
break;
}
case 5: {
// 5,概率,最大生命万分比,攻击力倍率上限 — 额外 flat 伤害(2051 等)
if (!roll(ctx, p[1] ?? 0)) break;
const hp = services.world.getComponent(target, HealthComp);
if (!hp || hp.maxHp <= 0) break;
const baseAtk = services.attributes.getAttr(ctx.caster, AttrId.Attack);
const percentDmg = Math.floor(hp.maxHp * Math.max(0, p[2] ?? 0) / 10000);
const capMul = Math.max(0, p[3] ?? 0);
const bonus = capMul > 0
? Math.min(percentDmg, Math.floor(baseAtk * capMul))
: percentDmg;
if (bonus > 0) {
const prev = ctx.vars.get('__flatBonusDamage') ?? 0;
ctx.vars.set('__flatBonusDamage', prev + bonus);
}
break;
}
default:
break;
}
}
}
/**
* atk_after:命中后、按当前单怪执行。
* 1 概率 buff / 2 标记 / 3 暴击后加 buff / 4 刀光 / 5 CD归0 / 6 伤害区域
*/
export function runAtkAfter(services: BattleServices, ctx: AbilityContext, raw: string): void {
if (!raw) return;
const target = ctx.targets[0];
if (target == null) return;
for (const p of splitSegments(raw)) {
const kind = p[0];
switch (kind) {
case 1: {
// 1,概率,buff类型,buff参数...
if (!roll(ctx, p[1] ?? 0)) break;
applyEncodedDebuff(services, ctx, target, p.slice(2));
break;
}
case 2: {
// 2,每次受击增加X(万分比),最大几次 — 标记叠层
const addWan = p[1] ?? 0;
const maxStacks = p[2] ?? 1;
stackDamageMark(services, target, addWan, maxStacks);
break;
}
case 3: {
// 3,属性,值,buff类型,类型参数 — 本次暴击后才加(buff类型语义同 atk_before 子3)
if (ctx.vars.get(VAR_LAST_CRIT) !== 1) break;
applyProbAttrBuff(
services,
ctx.caster,
p[1] ?? 1,
p[2] ?? 0,
p[3] ?? 1,
p[4] ?? 1,
false,
);
break;
}
case 4: {
// 4,概率,伤害系数[,体积] — 刀光(伤害系数→vars;体积供表现缩放,缺省 1)
if (ctx.vars.has('__bladeLightScale')) break;
if (!roll(ctx, p[1] ?? 0)) break;
ctx.vars.set('__bladeLightScale', p[2] ?? 1);
const size = p[3];
ctx.vars.set('__bladeLightSize', size != null && size > 0 ? size : 1);
break;
}
case 5: {
// 5,概率 — 主动技能 CD 归 0(须同步 OnTick timer,见 AbilitySystem.resetActiveSkillCooldowns)
if (!claimCastOnce(ctx, 'atkAfter5')) break;
if (!roll(ctx, p[1] ?? 0)) break;
services.abilities.resetActiveSkillCooldowns(ctx.caster);
break;
}
case 6: {
// 6,半径,倍数,间隔,总时间 — 伤害区域(先记 vars,表现层读一次)
if (!claimCastOnce(ctx, 'atkAfter6')) break;
ctx.vars.set('__dmgAreaR', p[1] ?? 0);
ctx.vars.set('__dmgAreaScale', p[2] ?? 1);
ctx.vars.set('__dmgAreaInterval', p[3] ?? 0);
ctx.vars.set('__dmgAreaDur', p[4] ?? 0);
break;
}
default:
break;
}
}
}
function applyHitTempAttr(
services: BattleServices,
caster: number,
attrCode: number,
value: number,
): void {
pushAttrModifier(services, caster, attrCode, value, {
group: HIT_TEMP_GROUP,
source: 'atk_hook',
});
}
/**
* atk_before/atk_after 子类型 3:概率或暴击后加属性。
* buffType 1=临时(次数) 2=计时(秒) 3=永久(叠加上限)。
* @param forCurrentHit true=atk_before(次数1仅当次命中);false=atk_after(作用于后续攻击)
*/
function applyProbAttrBuff(
services: BattleServices,
caster: number,
attrCode: number,
value: number,
buffType: number,
buffParam: number,
forCurrentHit: boolean,
): void {
const param = Math.max(1, buffParam ?? 1);
if (buffType === 2) {
pushAttrModifier(services, caster, attrCode, value, {
group: TIMED_BUFF_GROUP,
source: `atk_hook_t_${attrCode}`,
remain: param,
});
return;
}
if (buffType === 3) {
applyPermanentStackCap(services, caster, attrCode, value, param);
return;
}
// buffType === 1:临时 buff,类型参数=生效次数
if (forCurrentHit && param <= 1) {
pushAttrModifier(services, caster, attrCode, value, {
group: HIT_TEMP_GROUP,
source: 'atk_hook',
});
return;
}
pushAttrModifier(services, caster, attrCode, value, {
group: COUNT_BUFF_GROUP,
source: `atk_hook_c_${attrCode}`,
charges: param,
});
}
type AttrModifierOpts = {
group: string;
source: string;
remain?: number;
charges?: number;
};
function pushAttrModifier(
services: BattleServices,
caster: number,
attrCode: number,
value: number,
opts: AttrModifierOpts,
): void {
const attrName = ATTR_BY_CODE[attrCode];
if (!attrName) return;
if (!services.world.getComponent(caster, AttributeComp)) {
services.world.addComponent(caster, new AttributeComp());
}
if (attrCode === 1) {
services.attributes.addModifier(caster, {
attr: AttrId.Attack,
op: 'Mul',
value: 1 + value / 10000,
source: opts.source,
group: opts.group,
remain: opts.remain,
charges: opts.charges,
});
return;
}
if (attrCode === 6) {
services.attributes.addModifier(caster, {
attr: 'dmg_mul',
op: 'Add',
value: value - 1,
source: opts.source,
group: opts.group,
remain: opts.remain,
charges: opts.charges,
});
return;
}
if (attrCode === 3) {
services.attributes.addModifier(caster, {
attr: AttrId.CritRate,
op: 'Add',
value: value / 10000,
source: opts.source,
group: opts.group,
remain: opts.remain,
charges: opts.charges,
});
return;
}
if (attrCode === 4) {
services.attributes.addModifier(caster, {
attr: AttrId.CritDamage,
op: 'Add',
value: value / 10000,
source: opts.source,
group: opts.group,
remain: opts.remain,
charges: opts.charges,
});
return;
}
services.attributes.addModifier(caster, {
attr: attrName,
op: 'Add',
value,
source: opts.source,
group: opts.group,
remain: opts.remain,
charges: opts.charges,
});
}
/** buffType=3:同属性永久叠加上限(类型参数=上限,与 value 同单位) */
function applyPermanentStackCap(
services: BattleServices,
caster: number,
attrCode: number,
value: number,
capParam: number,
): void {
const group = `${PERM_BUFF_GROUP}_${attrCode}`;
const source = `atk_hook_p_${attrCode}`;
const attrComp = services.world.getComponent(caster, AttributeComp);
if (!attrComp) return;
if (attrCode === 1) {
const capWan = capParam * 100;
const existing = attrComp.modifiers.find(m => m.group === group && m.attr === AttrId.Attack);
const prevWan = existing ? Math.round((existing.value - 1) * 10000) : 0;
const nextWan = Math.min(capWan, prevWan + value);
attrComp.modifiers = attrComp.modifiers.filter(m => m.group !== group);
services.attributes.addModifier(caster, {
attr: AttrId.Attack,
op: 'Mul',
value: 1 + nextWan / 10000,
source,
group,
});
return;
}
if (attrCode === 6) {
const existing = attrComp.modifiers.find(m => m.group === group && m.attr === 'dmg_mul');
const prev = existing ? existing.value + 1 : 1;
const next = Math.min(capParam, prev + (value - 1));
attrComp.modifiers = attrComp.modifiers.filter(m => m.group !== group);
services.attributes.addModifier(caster, {
attr: 'dmg_mul',
op: 'Add',
value: next - 1,
source,
group,
});
return;
}
const existing = attrComp.modifiers.find(m => m.group === group && m.attr === ATTR_BY_CODE[attrCode]);
const prev = existing?.value ?? 0;
const next = Math.min(capParam, prev + value);
attrComp.modifiers = attrComp.modifiers.filter(m => m.group !== group);
services.attributes.addModifier(caster, {
attr: ATTR_BY_CODE[attrCode],
op: 'Add',
value: next,
source,
group,
});
}
function applyMaxAspdModifier(
services: BattleServices,
caster: number,
durationSec: number,
group: string,
): void {
const maxAspd = services.world.getComponent(caster, CombatComp)?.maxAttackSpeed || 20;
services.attributes.addModifier(caster, {
attr: AttrId.AttackSpeed,
op: 'Override',
value: maxAspd > 0 ? maxAspd : 20,
source: 'atk_before_1',
group,
remain: durationSec,
});
}
function queueComboAttacks(
services: BattleServices,
caster: number,
target: number,
count: number,
): void {
const world = services.world;
let combo = world.getComponent(caster, ComboAttackComp);
if (!combo) {
combo = new ComboAttackComp();
world.addComponent(caster, combo);
}
combo.pending += count;
combo.target = target;
combo.useMaxAspd = true;
}
function monsterHasState(services: BattleServices, target: number, stateId: number): boolean {
if (stateId <= 0) return false;
// 怪物状态 N 对应 debuff_N(流血/晕眩等),351/355 等 atk_before 需识别
if (services.buffs.hasTag(target, `debuff_${stateId}`)) return true;
const tags = services.world.getComponent(target, TagComp);
if (!tags) return false;
return tags.tags.includes(`state_${stateId}`) || tags.tags.includes(String(stateId));
}
/** 燃烧/流血:立刻首跳,再按间隔跳至持续时间结束;DoT 不再走词条 atk_after */
function _scheduleDotDamage(
services: BattleServices,
ctx: AbilityContext,
target: number,
opts: {
intervalSec: number;
scale: number;
durationSec: number;
damageType: 'burn_dot' | 'bleed_dot';
},
): void {
const interval = Math.max(0.05, opts.intervalSec);
const endAt = (services.clock.time ?? 0) + Math.max(0.05, opts.durationSec);
const tick = (): void => {
if (!services.world.isAlive(ctx.caster) || !services.world.isAlive(target)) return;
if ((services.clock.time ?? 0) > endAt) return;
services.damage.dealToTarget(ctx.caster, target, {
scale: opts.scale,
allowCrit: false,
damageType: opts.damageType,
}, ctx);
if ((services.clock.time ?? 0) + interval <= endAt) {
services.clock.schedule(interval, tick);
}
};
tick();
}
function applyEncodedDebuff(
services: BattleServices,
ctx: AbilityContext,
target: number,
buffParts: number[],
): void {
const buffType = buffParts[0] ?? 0;
// 1燃烧 2流血 3减速 4晕眩 5击退 6恶鬼啃食 7感电
const buffId = `debuff_${buffType}`;
if (buffType === 4) {
const dur = buffParts[1] ?? 1;
if (services.config.getBuff(buffId)) {
services.buffs.add(target, buffId, ctx.caster, 1, { duration: dur });
}
return;
}
if (buffType === 3) {
// 减速:参数为「减速量」。>1 按万分比(2000=减速20%→移速×0.8);≤1 为直接剩余倍率
const raw = buffParts[1] ?? 0.2;
const dur = buffParts[2] ?? 1;
const speedMul = raw > 1
? Math.max(0, 1 - raw / 10000)
: Math.max(0, raw);
if (services.config.getBuff(buffId)) {
services.buffs.add(target, buffId, ctx.caster, 1, { duration: dur });
}
if (!services.world.getComponent(target, AttributeComp)) {
services.world.addComponent(target, new AttributeComp());
}
services.attributes.addModifier(target, {
attr: 'move_speed_mul',
op: 'Mul',
value: speedMul,
source: buffId,
group: buffId,
remain: dur,
});
return;
}
if (buffType === 1 || buffType === 2) {
const interval = Math.max(0.05, buffParts[1] ?? 1);
const scale = buffParts[2] ?? 1;
const dur = buffParts[3] ?? 2;
const damageType = buffType === 1 ? 'burn_dot' : 'bleed_dot';
if (services.config.getBuff(buffId)) {
services.buffs.add(target, buffId, ctx.caster, 1, { duration: dur });
}
_scheduleDotDamage(services, ctx, target, {
intervalSec: interval,
scale,
durationSec: dur,
damageType,
});
return;
}
if (buffType === 5) {
applySkillTableKnockback(services, ctx, target, buffParts[1] ?? 0);
return;
}
if (buffType === 6) {
const percentWan = buffParts[1] ?? 0;
const stackCap = buffParts[2] ?? 1;
const bossWan = buffParts[3] ?? percentWan;
const dur = buffParts[4] ?? 3;
if (services.config.getBuff(buffId)) {
services.buffs.add(target, buffId, ctx.caster, 1, { duration: dur });
}
const stacks = Math.min(stackCap, 1);
const isBoss = services.world.getComponent(target, TagComp)?.tags.includes(EntityTag.Boss);
const wan = isBoss ? bossWan : percentWan;
const interval = 1;
const endAt = (services.clock.time ?? 0) + dur;
const tick = (): void => {
if (!services.world.isAlive(target) || !services.world.isAlive(ctx.caster)) return;
if ((services.clock.time ?? 0) > endAt) return;
services.damage.dealToTarget(ctx.caster, target, {
scale: 1,
percentMaxHpWan: wan * stacks,
allowCrit: false,
}, ctx);
if ((services.clock.time ?? 0) + interval <= endAt) {
services.clock.schedule(interval, tick);
}
};
services.clock.schedule(interval, tick);
return;
}
if (buffType === 7) {
const extraWan = buffParts[1] ?? 0;
const dur = buffParts[2] ?? 2;
if (services.config.getBuff(buffId)) {
services.buffs.add(target, buffId, ctx.caster, 1, { duration: dur });
}
if (!services.world.getComponent(target, AttributeComp)) {
services.world.addComponent(target, new AttributeComp());
}
services.attributes.addModifier(target, {
attr: 'dmg_taken_mul',
op: 'Mul',
value: 1 + extraWan / 10000,
source: buffId,
group: buffId,
remain: dur,
});
return;
}
if (services.config.getBuff(buffId)) {
services.buffs.add(target, buffId, ctx.caster, 1);
}
}
/** Skill 表击退(像素);须本次有伤害且目标可击退 */
export function applySkillTableKnockback(
services: BattleServices,
ctx: AbilityContext,
target: number,
pixels: number,
): void {
if (pixels <= 0) return;
if ((ctx.vars.get(VAR_LAST_DAMAGE_AMOUNT) ?? 0) <= 0) return;
if (isKnockbackImmune(services, target)) return;
if (applyKnockback(services, ctx, target, pixels)) {
services.events.emit(BattleEventFactory.custom(KNOCKBACK_APPLIED_EVENT, {
target,
caster: ctx.caster,
}));
}
}
/**
* 命中后击退:改 MovementComp 世界坐标,更新格坐标,打断 reachedGoal 攻击态并重寻路。
* @param pixels 击退距离(逻辑像素)
* @returns 是否实际发生了位移
*/
function applyKnockback(
services: BattleServices,
ctx: AbilityContext,
target: number,
pixels: number,
): boolean {
if (pixels <= 0) return false;
if (!services.world.isAlive(target)) return false;
const move = services.world.getComponent(target, MovementComp);
const tf = services.world.getComponent(target, TransformComp);
if (!move || !tf) return false;
const targetPos = resolveEntityWorldPos(services, move, tf);
const tx = targetPos.x;
const ty = targetPos.y;
// 施法者世界坐标(塔可能只有 TransformComp)
const casterMove = services.world.getComponent(ctx.caster, MovementComp);
const casterTf = services.world.getComponent(ctx.caster, TransformComp);
let cx: number;
let cy: number;
if (casterMove) {
const casterPos = resolveEntityWorldPos(services, casterMove, casterTf);
cx = casterPos.x;
cy = casterPos.y;
} else if (casterTf) {
const w = services.grid.cellToWorld({ row: casterTf.row, col: casterTf.col });
cx = w.x;
cy = w.y;
} else {
return false;
}
let dx = tx - cx;
let dy = ty - cy;
let len = Math.sqrt(dx * dx + dy * dy);
if (len < 1e-3) {
// 重合时:背离攻击目标(向出怪口一侧),再不行默认沿 -Y(逻辑坐标向上)
if (move.hasAttackGoal) {
dx = tx - move.goalWorldX;
dy = ty - move.goalWorldY;
len = Math.sqrt(dx * dx + dy * dy);
}
if (len < 1e-3) {
dx = 0;
dy = -1;
len = 1;
}
}
const campAreaId = resolveKnockbackCampArea(services, target);
let landing = resolveKnockbackLanding(services, tf, tx, ty, dx / len, dy / len, pixels, campAreaId);
if (!landing || knockbackDeltaTooSmall(tx, ty, landing.x, landing.y)) {
const retreat = pathRetreatDir(services, move, tx, ty);
if (retreat) {
landing = resolveKnockbackLanding(services, tf, tx, ty, retreat.nx, retreat.ny, pixels, campAreaId);
}
}
if (!landing || knockbackDeltaTooSmall(tx, ty, landing.x, landing.y)) return false;
move.worldX = landing.x;
move.worldY = landing.y;
tf.row = landing.cell.row;
tf.col = landing.cell.col;
services.spatial.updateEntity(target, landing.cell);
// 打断当前路径;重寻路后若仍在停步距离内会再次 reachedGoal,靠 forcePresentSync 同步表现
move.reachedGoal = false;
move.path = [];
move.pathIndex = 0;
move.forcePresentSync = true;
services.movement.assignPath(target);
return true;
}
/**
* 位移是否小到可以当成没击退。
* @param fromX 起点 X
* @param fromY 起点 Y
* @param toX 落点 X
* @param toY 落点 Y
*/
function knockbackDeltaTooSmall(fromX: number, fromY: number, toX: number, toY: number): boolean {
return Math.abs(toX - fromX) < 0.5 && Math.abs(toY - fromY) < 0.5;
}
/**
* 沿进路后退:背离下一路径格 / 攻击目标(营地),把怪往出怪口推。
* @param services 战斗服务
* @param move 目标移动
* @param tx 当前逻辑 X
* @param ty 当前逻辑 Y
* @returns 单位方向;无进路则 null
*/
function pathRetreatDir(
services: BattleServices,
move: MovementComp,
tx: number,
ty: number,
): { nx: number; ny: number } | null {
let dx = 0;
let dy = 0;
if (move.pathIndex < move.path.length) {
const next = services.grid.cellToWorld(move.path[move.pathIndex]);
dx = tx - next.x;
dy = ty - next.y;
} else if (move.hasAttackGoal) {
dx = tx - move.goalWorldX;
dy = ty - move.goalWorldY;
} else {
return null;
}
const len = Math.sqrt(dx * dx + dy * dy);
if (len < 1e-3) return null;
return { nx: dx / len, ny: dy / len };
}
/**
* 击退目标所属阵营区域(多阵营距离场用)。
* @param services 战斗服务
* @param target 被击退实体
*/
function resolveKnockbackCampArea(services: BattleServices, target: number): number | undefined {
const meta = services.world.getComponent(target, UnitMetaComp);
const port = meta?.spawnPoint ?? 0;
if (port < 1) return undefined;
return services.topology.getSpawnPortCampArea(port);
}
/**
* 格是否可走且能寻路到攻击点(击退不得离开距离场)。
* @param services 战斗服务
* @param cell 候选格
* @param campAreaId 阵营区域
*/
function isKnockbackPathable(services: BattleServices, cell: { row: number; col: number }, campAreaId?: number): boolean {
if (!services.grid.inBounds(cell) || !services.grid.isWalkable(cell)) return false;
return Number.isFinite(services.pathfinder.getPathCost(cell, campAreaId));
}
/**
* 把世界坐标收进格内,避免贴边被 worldToCell 判到邻格。
* @param grid 逻辑地图
* @param x 逻辑 X
* @param y 逻辑 Y
* @param cell 目标格
*/
function clampWorldToCell(grid: BattleServices['grid'], x: number, y: number, cell: { row: number; col: number }): { x: number; y: number } {
const pad = 1;
const minX = cell.col * grid.cellSize + pad;
const maxX = (cell.col + 1) * grid.cellSize - pad;
const minY = cell.row * grid.cellSize + pad;
const maxY = (cell.row + 1) * grid.cellSize - pad;
return {
x: Math.max(minX, Math.min(maxX, x)),
y: Math.max(minY, Math.min(maxY, y)),
};
}
/**
* 沿单位方向放置击退落点;越界/不可寻路则回退采样。起点已可寻路时不改落到场外格。
* @param services 战斗服务
* @param tf 目标格坐标
* @param tx 当前逻辑 X
* @param ty 当前逻辑 Y
* @param nx 方向 X
* @param ny 方向 Y
* @param pixels 击退距离
* @param campAreaId 阵营区域
* @returns 落点;无可行进格则 null
*/
function resolveKnockbackLanding(services: BattleServices, tf: TransformComp, tx: number, ty: number, nx: number, ny: number, pixels: number, campAreaId?: number): { x: number; y: number; cell: { row: number; col: number } } | null {
const grid = services.grid;
const maxX = grid.cols * grid.cellSize - 1;
const maxY = grid.rows * grid.cellSize - 1;
const step = Math.max(8, grid.cellSize * 0.25);
for (let dist = pixels; dist > 0; dist -= step) {
const sx = Math.max(1, Math.min(maxX, tx + nx * dist));
const sy = Math.max(1, Math.min(maxY, ty + ny * dist));
const c = grid.worldToCell(sx, sy);
if (!isKnockbackPathable(services, c, campAreaId)) continue;
const w = clampWorldToCell(grid, sx, sy, c);
return { x: w.x, y: w.y, cell: c };
}
const origin = { row: tf.row, col: tf.col };
if (isKnockbackPathable(services, origin, campAreaId)) return null;
const escape = findNearestPathable(services, origin, campAreaId);
if (!escape) return null;
const w = grid.cellToWorld(escape);
return { x: w.x, y: w.y, cell: escape };
}
function isKnockbackImmune(services: BattleServices, target: number): boolean {
const move = services.world.getComponent(target, MovementComp);
if (!move) return true;
if (move.spawnProtected || move.imprisoned || move.imprisonBy != null) return true;
const meta = services.world.getComponent(target, UnitMetaComp);
if (!meta) return false;
const parsed = parseMonsterUnitDefId(meta.unitDefId);
const monsterId = parsed?.monsterId ?? parseLegacyMonsterId(meta.unitDefId);
if (monsterId <= 0) return false;
const row = gg.data.table.getTableData<ITableMonster>(TableNames.Monster, monsterId);
return !!row && KNOCKBACK_IMMUNE_MONSTER_TYPES.has(row.type);
}
/** 逻辑世界坐标:未写入 worldX/Y 时回退格心 */
function resolveEntityWorldPos(
services: BattleServices,
move: MovementComp,
tf: TransformComp | null,
): { x: number; y: number } {
if (move.worldX !== 0 || move.worldY !== 0) {
return { x: move.worldX, y: move.worldY };
}
if (tf) {
return services.grid.cellToWorld({ row: tf.row, col: tf.col });
}
return { x: 0, y: 0 };
}
/**
* BFS 找最近可寻路格(含起点)。
* @param services 战斗服务
* @param from 起点
* @param campAreaId 阵营区域
*/
function findNearestPathable(services: BattleServices, from: { row: number; col: number }, campAreaId?: number): { row: number; col: number } | null {
if (isKnockbackPathable(services, from, campAreaId)) return from;
const grid = services.grid;
const queue = [from];
const visited = membershipFromArray<number>([from.row * grid.cols + from.col]);
let head = 0;
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
while (head < queue.length) {
const cur = queue[head++];
for (const [dr, dc] of dirs) {
const next = { row: cur.row + dr, col: cur.col + dc };
if (!grid.inBounds(next)) continue;
const key = next.row * grid.cols + next.col;
if (visited.has(key)) continue;
membershipAdd(visited, key);
if (isKnockbackPathable(services, next, campAreaId)) return next;
queue.push(next);
}
}
return null;
}
function stackDamageMark(
services: BattleServices,
target: number,
addWan: number,
maxStacks: number,
): void {
let attr = services.world.getComponent(target, AttributeComp);
if (!attr) {
attr = new AttributeComp();
services.world.addComponent(target, attr);
}
const source = 'dmg_mark';
const existing = attr.modifiers.find(m => m.source === source && m.attr === 'dmg_taken_mul');
const stacks = existing
? Math.min(maxStacks, Math.round(((existing.value - 1) * 10000) / addWan) + 1)
: 1;
const mul = 1 + (addWan * stacks) / 10000;
attr.modifiers = attr.modifiers.filter(m => !(m.source === source && m.attr === 'dmg_taken_mul'));
attr.modifiers.push({
attr: 'dmg_taken_mul',
op: 'Mul',
value: mul,
source,
group: 'dmg_mark',
});
}