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.
947 lines
33 KiB
947 lines
33 KiB
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',
|
|
});
|
|
}
|
|
|