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 = { 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; 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(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([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', }); }