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

814 lines
32 KiB

import type { World, EntityId } from 'db://assets/mx/module/ecs';
import type { ISystem } from 'db://assets/mx/module/ecs';
import { BattlePhase } from '../core/BattlePhase';
import type { AbilityDef, ConditionSpec, CostSpec } from '../config/BattleConfigTypes';
import type { DeferredTimelineSlice, PresentationLockChangeMeta } from './PresentationPipeline';
import { ConditionRegistry } from '../registry/EffectRegistry';
import { BattleEventFactory } from '../events/BattleEvents';
import type { BattleEventBus } from '../events/BattleEventBus';
import type { BattleClock } from '../core/BattleClock';
import type { IConfigProvider } from '../config/IConfigProvider';
import { createAbilityContext } from './AbilityContext';
import type { AbilityContext } from './AbilityContext';
import { AbilitySetComp, AttrId, CombatComp, ComboAttackComp, UnitMetaComp } from '../ecs/Components';
import type { AttributeSystem } from './AttributeSystem';
import type { TargetResolver } from './TargetResolver';
import type { EffectExecutor } from './EffectExecutor';
import type { BattleServices } from './BattleServices';
import { createMembershipSet, membershipAdd, type MembershipSet } from 'db://assets/mx/tools/MembershipSet';
/** OnTick 触发器的运行时状态 */
interface TickAbilityState {
/** 技能 ID */
abilityId: string;
/** 触发间隔(秒);动态间隔时为最近一次计算值 */
interval: number;
/** 距下次触发剩余时间 */
timer: number;
/** 是否动态间隔(tr.interval 未指定时按攻速属性实时计算) */
dynamic: boolean;
/** 是否可被施法打断(true=施法期间冻结本触发器) */
interruptibleByCast: boolean;
}
/** 次数型连击段内临时最快攻速(不用 remain,避免按秒持续) */
const COMBO_ASPD_GROUP = 'combo_aspd';
/** base_para 连发:interval=0 时推迟到下一逻辑帧,避免 finish 重入丢段 */
const PARA6_ZERO_INTERVAL_DEFER_SEC = 0;
/** base_para 连发:施放失败后的重试间隔(含 interval=0) */
const PARA6_BURST_RETRY_SEC = 0.08;
/** 能力系统:Trigger + Timeline + CD */
export class AbilitySystem implements ISystem {
readonly priority = 30;
enabled = true;
private _tickStates = new Map<EntityId, TickAbilityState[]>();
private _eventListeners = new Map<string, MembershipSet<{ entity: EntityId; abilityId: string }>>();
/** 实体 → 施法结束时刻(秒,battle clock);此时刻前抑制该实体 interruptibleByCast 的 OnTick */
private _castingUntil = new Map<EntityId, number>();
/** 表现 deferred 未 finish 前锁定施法(冻结 interruptibleByCast OnTick) */
private _presentationLocked = createMembershipSet<EntityId>();
/** 全局施法实例计数(写入 ctx.vars.__castInstanceId,表现 cue 去重用) */
private _castInstanceCounter = 0;
constructor(
private readonly _world: World,
private readonly _config: IConfigProvider,
private readonly _attributes: AttributeSystem,
private readonly _targets: TargetResolver,
private readonly _effects: EffectExecutor,
private readonly _clock: BattleClock,
private readonly _events: BattleEventBus,
private readonly _services: BattleServices,
) {}
/**
* 硬解绑:死亡 / 卖出。取消表现、清施法锁与连击。
* 词条刷新 / 升级请用 `bindEntity(id, { preserveRuntime: true })`。
*/
unbindEntity(entityId: EntityId): void {
this._tickStates.delete(entityId);
this._castingUntil.delete(entityId);
this._presentationLocked.delete(entityId);
this._clearComboMaxAspd(entityId);
if (this._world.hasComponent(entityId, ComboAttackComp)) {
this._world.removeComponent(entityId, ComboAttackComp);
}
this._services.presentation.cancelByCaster(entityId);
this._clearEventListenersFor(entityId);
}
/** 清空全部运行时状态(战斗结束销毁会话时调用) */
clear(): void {
this._tickStates.clear();
this._eventListeners.clear();
this._castingUntil.clear();
this._presentationLocked.clear();
this._castInstanceCounter = 0;
}
/** 设置/清除 PresentationPipeline 施法锁 */
setPresentationLocked(
entityId: EntityId,
locked: boolean,
meta?: PresentationLockChangeMeta,
): void {
if (locked) {
membershipAdd(this._presentationLocked, entityId);
return;
}
this._presentationLocked.delete(entityId);
const combo = this._world.getComponent(entityId, ComboAttackComp);
if (!combo || combo.pending <= 0) {
this._clearComboMaxAspd(entityId);
}
this._tryReadyActives(entityId);
}
/** 表现 finish 后调度 pending 连发(由 PresentationPipeline 专用回调触发) */
flushComboAfterPresentation(entityId: EntityId): void {
if (this._hasReadyActive(entityId)) return;
this._flushPendingCombo(entityId);
}
/**
* 绑定技能触发器(OnTick / OnEvent)。
* @param preserveRuntime 词条/升级热更:保留施法锁、进行中表现、OnTick 剩余时间;不 cancel 表现
*/
bindEntity(entityId: EntityId, options?: { preserveRuntime?: boolean }): void {
if (options?.preserveRuntime) {
this._rebindPreservingRuntime(entityId);
return;
}
this.unbindEntity(entityId);
this._installTriggers(entityId, null);
}
private _clearEventListenersFor(entityId: EntityId): void {
this._eventListeners.forEach(set => {
const remove: Array<{ entity: EntityId; abilityId: string }> = [];
for (const entry of set.keys()) {
if (entry.entity === entityId) remove.push(entry);
}
for (const entry of remove) set.delete(entry);
});
}
/** 热更 AbilityDef:不打断大招/普攻表现,不重置 CD 锁与连击 */
private _rebindPreservingRuntime(entityId: EntityId): void {
const prevTicks = this._tickStates.get(entityId) ?? [];
const castingUntil = this._castingUntil.get(entityId);
const presentationLocked = this._presentationLocked.has(entityId);
this._tickStates.delete(entityId);
this._clearEventListenersFor(entityId);
this._installTriggers(entityId, prevTicks);
if (castingUntil != null) this._castingUntil.set(entityId, castingUntil);
if (presentationLocked) membershipAdd(this._presentationLocked, entityId);
}
private _installTriggers(
entityId: EntityId,
prevTicks: readonly TickAbilityState[] | null,
): void {
const abilities = this._world.getComponent(entityId, AbilitySetComp);
if (!abilities) return;
const tickStates: TickAbilityState[] = [];
for (const abilityId of abilities.abilityIds) {
const def = this._config.getAbility(abilityId);
if (!def) continue;
for (const tr of def.triggers) {
if (tr.type === 'OnTick') {
const dynamic = tr.interval == null;
const interval = tr.interval ?? (1 / this._attributes.getFinalAttackSpeed(entityId));
const prev = prevTicks?.find(t => t.abilityId === abilityId);
const timer = prev
? Math.min(Math.max(0, prev.timer), interval)
: (tr.firstDelaySec != null && Number.isFinite(tr.firstDelaySec)
? Math.max(0, tr.firstDelaySec)
: interval * 0.5);
tickStates.push({
abilityId,
interval,
timer,
dynamic,
interruptibleByCast: tr.interruptibleByCast === true,
});
} else if (tr.type === 'OnEvent' && tr.event) {
if (!this._eventListeners.has(tr.event)) {
this._eventListeners.set(tr.event, createMembershipSet());
}
membershipAdd(this._eventListeners.get(tr.event)!, { entity: entityId, abilityId });
}
}
}
if (tickStates.length) this._tickStates.set(entityId, tickStates);
}
/**
* @param entityId 实体 ID
* @param triggerType 触发器类型
*/
fireTrigger(entityId: EntityId, triggerType: string): void {
this.tryCast(entityId, triggerType);
}
/**
* @param eventName 事件名
* @param payload 可选事件载荷
*/
fireEvent(eventName: string, payload?: { killer?: EntityId; victim?: EntityId }): void {
const set = this._eventListeners.get(eventName);
if (!set) return;
for (const { entity, abilityId } of set.keys()) {
const ctx = createAbilityContext(this._services, entity, abilityId);
if (payload?.victim) ctx.targets = [payload.victim];
this._castWithDef(entity, abilityId, ctx);
}
}
/**
* @param entityId 施放者实体 ID
* @param abilityIdOrTrigger 技能 ID 或触发器类型
* @param manualTarget 手动指定目标
* @returns 是否成功施放
*/
tryCast(
entityId: EntityId,
abilityIdOrTrigger?: string,
manualTarget?: EntityId,
options?: { comboAttack?: boolean },
): boolean {
if (this._services.castBlockPredicate?.(entityId)) {
return false;
}
if (this._services.buffs.hasTag(entityId, 'debuff_8') || this._services.buffs.hasTag(entityId, 'debuff_13')) {
return false;
}
const abilities = this._world.getComponent(entityId, AbilitySetComp);
if (!abilities) {
return false;
}
for (const abilityId of abilities.abilityIds) {
const def = this._config.getAbility(abilityId);
if (!def) {
continue;
}
if (abilityIdOrTrigger && abilityId !== abilityIdOrTrigger) {
const matchTrigger = def.triggers.some(t => t.type === abilityIdOrTrigger);
if (!matchTrigger) continue;
}
if (!options?.comboAttack) {
const combo = this._world.getComponent(entityId, ComboAttackComp);
// 仅挡可打断普攻;大招 OnTick(interruptibleByCast=false)须能在 pending 期间释放
const normInterruptible = def.triggers.some(
(t) => t.type === 'OnTick' && t.interruptibleByCast === true,
);
if (combo && combo.pending > 0 && normInterruptible) {
if (this._presentationLocked.has(entityId)) continue;
// 施法锁已解但连发未调度:补 flush,避免表现层未 finish 后 pending 永久卡普攻
if (!combo.burstTimerPending) {
this._flushPendingCombo(entityId);
}
continue;
}
}
const ctx = createAbilityContext(this._services, entityId, abilityId, manualTarget ? [manualTarget] : []);
const meta = this._world.getComponent(entityId, UnitMetaComp);
if (meta?.level != null && meta.level > 0) {
ctx.level = meta.level;
}
if (!this._passAbilityConditions(def, ctx)) {
continue;
}
if (!this._passCosts(entityId, def, options)) {
continue;
}
if (manualTarget) {
if (!this._world.isAlive(manualTarget)) continue;
ctx.targets = [manualTarget];
} else if (def.targeting) {
ctx.targets = this._targets.resolve(ctx, def.targeting);
if (ctx.targets.length === 0) {
continue;
}
}
this._castWithDef(entityId, abilityId, ctx, options);
return true;
}
return false;
}
tick(dt: number): void {
if (this._services.phase !== BattlePhase.COMBAT) return;
const world = this._world;
const now = this._clock.time;
// 用 forEach 遍历 Map,避免小游戏 for-of/Map spread 异常导致普攻 tick 不跑
this._tickStates.forEach((states, entityId) => {
if (!world.isAlive(entityId)) return;
if (this._services.holdAbilityTickPredicate?.(entityId)) return;
// 施法/表现未 finish:禁止再 tryCast,但 timer 仍扣减,
// 否则有效周期 = 表现时长 + 1/aspd,配表攻速永远打不满
const castingUntil = this._castingUntil.get(entityId);
const isCasting = this._presentationLocked.has(entityId)
|| (castingUntil != null && now < castingUntil);
for (const s of states) {
if (s.dynamic) {
s.interval = 1 / this._attributes.getFinalAttackSpeed(entityId);
}
s.timer -= dt;
}
if (isCasting) return;
if (this._tryReadyActives(entityId)) return;
// 普攻:可打断 OnTick
for (const s of states) {
if (!s.interruptibleByCast) continue;
if (s.timer > 0) continue;
const ok = this.tryCast(entityId, s.abilityId);
if (ok) s.timer = s.interval;
}
});
this._tickCooldowns(dt);
}
/**
* 已就绪主动按 priority 降序放一个;其余 timer 保持就绪,等施法锁解开再放。
* @param entityId 实体
* @returns 是否已施放
*/
private _tryReadyActives(entityId: EntityId): boolean {
if (this._presentationLocked.has(entityId)) return false;
const until = this._castingUntil.get(entityId);
if (until != null && this._clock.time < until) return false;
const states = this._tickStates.get(entityId);
if (!states) return false;
const ready: TickAbilityState[] = [];
for (let i = 0; i < states.length; i++) {
const s = states[i];
if (s.interruptibleByCast) continue;
if (s.timer > 0) continue;
ready.push(s);
}
this._orderReadyActives(ready);
for (let i = 0; i < ready.length; i++) {
const s = ready[i];
if (!this.tryCast(entityId, s.abilityId)) continue;
s.timer = s.interval;
return true;
}
return false;
}
/**
* 是否有已就绪且未放的主动。
* @param entityId 实体
*/
private _hasReadyActive(entityId: EntityId): boolean {
const states = this._tickStates.get(entityId);
if (!states) return false;
for (let i = 0; i < states.length; i++) {
const s = states[i];
if (s.interruptibleByCast) continue;
if (s.timer > 0) continue;
return true;
}
return false;
}
/**
* priority 降序,同值保持原序。
* @param ready 已就绪主动
*/
private _orderReadyActives(ready: TickAbilityState[]): void {
for (let i = 1; i < ready.length; i++) {
const cur = ready[i];
const pc = this._config.getAbility(cur.abilityId)?.priority ?? 0;
let j = i - 1;
while (j >= 0) {
const pj = this._config.getAbility(ready[j].abilityId)?.priority ?? 0;
if (pj >= pc) break;
ready[j + 1] = ready[j];
j -= 1;
}
ready[j + 1] = cur;
}
}
/** 递减所有实体的技能冷却 */
private _tickCooldowns(dt: number): void {
this._world.view(AbilitySetComp).forEach((_id, abilities) => {
if (!abilities.cooldowns) return;
for (const key of Object.keys(abilities.cooldowns)) {
const v = abilities.cooldowns[key] - dt;
abilities.cooldowns[key] = v > 0 ? v : 0;
}
});
}
private _castWithDef(
entityId: EntityId,
abilityId: string,
ctx: AbilityContext,
options?: { comboAttack?: boolean },
): void {
if (options?.comboAttack) {
ctx.vars.set('__comboAttack', 1);
} else {
this._queuePara6Burst(entityId, abilityId, ctx);
}
this._services.buffs.consumeAttackBuffs(entityId);
const def = this._config.getAbility(abilityId)!;
const castInstanceId = ++this._castInstanceCounter;
ctx.vars.set('__castInstanceId', castInstanceId);
const deferPipeline = def.timeline.some(ev =>
ev.effects.some(e => e.type === 'PlayPresentation' && e.params?.deferPipeline !== false),
);
// 施法期间抑制本实体可被打断的 OnTick(普攻)。castDuration 缺省时按 timeline 末事件 + 100ms 兜底。
let durationSec = 0;
if (def.castDuration && def.castDuration > 0) {
durationSec = def.castDuration;
this._castingUntil.set(entityId, this._clock.time + durationSec);
} else if (!deferPipeline) {
const lastAt = def.timeline.length ? def.timeline[def.timeline.length - 1].at : 0;
durationSec = lastAt / 1000 + 0.1;
if (durationSec > 0) this._castingUntil.set(entityId, this._clock.time + durationSec);
}
if (durationSec > 0) {
this._clock.schedule(durationSec, () => {
if (!this._world.isAlive(entityId)) return;
this._events.emit(BattleEventFactory.abilityCastEnd(
entityId, abilityId, castInstanceId, durationSec,
));
});
}
const deferredSlices: DeferredTimelineSlice[] = [];
for (const ev of def.timeline) {
const presentationFx = ev.effects.filter(e => e.type === 'PlayPresentation');
const logicFx = ev.effects.filter(e => e.type !== 'PlayPresentation');
if (presentationFx.length) {
this._scheduleTimelineBatch(entityId, ev.at, presentationFx, ctx, ev.conditions);
}
if (logicFx.length) {
if (deferPipeline) {
deferredSlices.push({
at: ev.at,
index: ev.stageIndex ?? deferredSlices.length,
effects: logicFx,
conditions: ev.conditions,
});
} else {
this._scheduleTimelineBatch(entityId, ev.at, logicFx, ctx, ev.conditions);
}
}
}
if (deferPipeline) {
this._services.presentation.register(castInstanceId, ctx, deferredSlices);
}
if (!options?.comboAttack) {
this._applyCooldowns(entityId, def);
}
this._events.emit(BattleEventFactory.abilityCast(ctx));
}
private _scheduleTimelineBatch(
entityId: EntityId,
atMs: number,
effects: AbilityDef['timeline'][0]['effects'],
ctx: AbilityContext,
conditions?: ConditionSpec[],
): void {
const delaySec = atMs / 1000;
this._clock.schedule(delaySec, () => {
if (!this._world.isAlive(entityId)) return;
if (conditions?.length) {
for (const c of conditions) {
if (!ConditionRegistry.evaluate(this._services, ctx, c.type, c.params ?? {})) return;
}
}
this._effects.executeAll(ctx, effects);
});
}
/** 施放成功后写入 Cooldown 计时 */
private _applyCooldowns(entityId: EntityId, def: AbilityDef): void {
if (!def.costs) return;
const abilities = this._world.getComponent(entityId, AbilitySetComp);
if (!abilities) return;
for (const cost of def.costs) {
if (cost.type !== 'Cooldown') continue;
const key = (cost.params?.abilityId as string) ?? def.id;
const rawCd = (cost.params?.cd as number) ?? (cost.params?.duration as number) ?? 0;
const cd = this._attributes.getFinalCooldown(entityId, rawCd);
if (cd > 0) abilities.cooldowns[key] = cd;
}
}
private _passAbilityConditions(def: AbilityDef, ctx: AbilityContext): boolean {
if (!def.conditions) return true;
return this._passConditions(def.conditions, ctx);
}
private _passConditions(conditions: ConditionSpec[], ctx: AbilityContext): boolean {
for (const c of conditions) {
if (!ConditionRegistry.evaluate(this._services, ctx, c.type, c.params ?? {})) return false;
}
return true;
}
private _passCosts(
entityId: EntityId,
def: AbilityDef,
options?: { comboAttack?: boolean },
): boolean {
if (!def.costs) return true;
for (const cost of def.costs) {
if (options?.comboAttack && cost.type === 'Cooldown') continue;
if (!this._applyCostCheck(entityId, cost)) return false;
}
return true;
}
private _applyCostCheck(entityId: EntityId, cost: CostSpec): boolean {
if (cost.type === 'Cooldown') {
const abilities = this._world.getComponent(entityId, AbilitySetComp);
const key = (cost.params?.abilityId as string) ?? '';
const cd = abilities?.cooldowns[key] ?? 0;
return cd <= 0;
}
return true;
}
/** 表现 finish 后:执行 atk_before 次数型连击 / base_para 连发 */
private _flushPendingCombo(entityId: EntityId): void {
if (!this._world.isAlive(entityId)) return;
const combo = this._world.getComponent(entityId, ComboAttackComp);
if (!combo || combo.pending <= 0) return;
if (combo.burstTimerPending) return;
if (combo.intervalSec > 0) {
this._schedulePara6ComboCast(entityId, combo.intervalSec);
return;
}
if (combo.useMaxAspd) {
this._executeComboCast(entityId);
} else {
// interval=0 连发:推迟一帧,避免在 finish 回调栈内同步连打丢段
this._schedulePara6ComboCast(entityId, PARA6_ZERO_INTERVAL_DEFER_SEC);
}
}
private _executeComboCast(entityId: EntityId): void {
const combo = this._world.getComponent(entityId, ComboAttackComp);
if (!combo || combo.pending <= 0) return;
combo.burstTimerPending = false;
const abilityId = combo.abilityId ?? this._findInterruptibleNormalAbility(entityId);
if (!abilityId) {
this._retryPara6ComboCast(entityId, combo);
return;
}
const def = this._config.getAbility(abilityId);
const fullFieldUlt = def?.targeting?.selector === 'AllEnemies';
if (!fullFieldUlt && combo.target != null && !this._world.isAlive(combo.target)) {
combo.target = null;
}
if (combo.useMaxAspd) {
this._applyComboMaxAspd(entityId);
}
// 全场大招(玉帝 type10):连发段仍走 AllEnemies 全目标,勿用 para6 单点 manualTarget
let burstTarget: EntityId | null | undefined;
if (fullFieldUlt) {
burstTarget = undefined;
} else if (this._hasTargetNumAdd(entityId)) {
// base_para 类型5:连发段须走完整索敌,每段可打多个目标(如火 1451+1455)
burstTarget = undefined;
} else {
burstTarget = this._resolveComboBurstTarget(entityId, combo, abilityId);
if (burstTarget === null) {
this._retryPara6ComboCast(entityId, combo);
return;
}
}
const ok = this.tryCast(
entityId,
abilityId,
burstTarget,
{ comboAttack: true },
);
if (!ok) {
this._retryPara6ComboCast(entityId, combo);
return;
}
if (burstTarget != null && !combo.usedTargets.includes(burstTarget)) {
combo.usedTargets.push(burstTarget);
}
combo.target = burstTarget ?? null;
combo.pending--;
if (combo.pending > 0) {
// para_6 连发(含间隔):下一段仅由表现 finish → flushComboAfterPresentation 调度
if (combo.useMaxAspd) {
this._scheduleNextComboCast(entityId, combo);
}
} else {
combo.target = null;
combo.usedTargets = [];
combo.burstTimerPending = false;
this._clearComboMaxAspd(entityId);
}
}
/** 连发索敌:优先未命中目标;原目标死亡时重新找范围内敌人,不清 pending */
private _hasTargetNumAdd(entityId: EntityId): boolean {
const numAdd = Math.max(0, Math.floor(
this._attributes.getAttr(entityId, 'target_num_add') || 0,
));
return numAdd > 0;
}
private _resolveComboBurstTarget(
entityId: EntityId,
combo: ComboAttackComp,
abilityId: string,
): EntityId | null {
if (!combo.useMaxAspd && combo.intervalSec > 0) {
const picked = this._pickPara6BurstTarget(entityId, combo);
if (picked != null && this._world.isAlive(picked)) return picked;
}
if (combo.target != null && this._world.isAlive(combo.target)) {
return combo.target;
}
const def = this._config.getAbility(abilityId);
if (!def?.targeting) return combo.target;
const ctx = createAbilityContext(this._services, entityId, abilityId);
const meta = this._world.getComponent(entityId, UnitMetaComp);
if (meta?.level != null && meta.level > 0) ctx.level = meta.level;
const candidates = this._targets.resolve(ctx, def.targeting)
.filter((id) => this._world.isAlive(id));
return candidates[0] ?? null;
}
private _scheduleNextComboCast(entityId: EntityId, combo: ComboAttackComp): void {
if (combo.useMaxAspd && combo.intervalSec <= 0) {
this._executeComboCast(entityId);
return;
}
const delay = combo.intervalSec > 0 ? combo.intervalSec : PARA6_ZERO_INTERVAL_DEFER_SEC;
this._schedulePara6ComboCast(entityId, delay);
}
/** 连发暂不可行:预约重试;useMaxAspd 也必须重试,否则 pending 永挡普攻 */
private _retryPara6ComboCast(entityId: EntityId, combo: ComboAttackComp): void {
if (combo.pending <= 0) return;
const delay = combo.useMaxAspd
? PARA6_BURST_RETRY_SEC
: (combo.intervalSec > 0 ? combo.intervalSec : PARA6_BURST_RETRY_SEC);
this._schedulePara6ComboCast(entityId, delay);
}
/** base_para code=6 连发:优先选取尚未被本波连发命中的敌人 */
private _pickPara6BurstTarget(entityId: EntityId, combo: ComboAttackComp): EntityId | null {
const abilityId = combo.abilityId;
if (!abilityId) return combo.target;
const def = this._config.getAbility(abilityId);
if (!def?.targeting) return combo.target;
const ctx = createAbilityContext(this._services, entityId, abilityId);
const meta = this._world.getComponent(entityId, UnitMetaComp);
if (meta?.level != null && meta.level > 0) ctx.level = meta.level;
const candidates = this._targets.resolve(ctx, def.targeting)
.filter(id => this._world.isAlive(id));
if (candidates.length === 0) return combo.target;
const used = combo.usedTargets;
for (const id of candidates) {
if (!used.includes(id)) return id;
}
return candidates[used.length % candidates.length] ?? combo.target;
}
/** base_para code=6:首段施放后排队连发(数量 + 间隔) */
private _queuePara6Burst(entityId: EntityId, abilityId: string, ctx: AbilityContext): void {
const burstAttrs = this._resolvePara6BurstAttrs(abilityId);
if (!burstAttrs) return;
const { countAttr, intervalAttr } = burstAttrs;
const count = Math.floor(this._attributes.getAttr(entityId, countAttr));
if (count <= 0) return;
const interval = Math.max(0, this._attributes.getAttr(entityId, intervalAttr));
const target = ctx.targets[0] ?? null;
let combo = this._world.getComponent(entityId, ComboAttackComp);
if (!combo) {
combo = new ComboAttackComp();
this._world.addComponent(entityId, combo);
}
combo.pending += count;
combo.target = target;
combo.abilityId = abilityId;
combo.intervalSec = interval;
combo.useMaxAspd = false;
combo.usedTargets = target != null ? [target] : [];
combo.burstTimerPending = false;
// 带间隔连发须等表现 finish 后再调度(见 flushComboAfterPresentation)
}
/** 按 AbilityDef 区分普攻/大招连发属性(不依赖 OnTick 运行时态) */
private _resolvePara6BurstAttrs(abilityId: string): { countAttr: string; intervalAttr: string } | null {
const def = this._config.getAbility(abilityId);
if (!def) return null;
const isNorm = def.triggers.some(
(t) => t.type === 'OnTick' && t.interruptibleByCast === true,
);
return {
countAttr: isNorm ? 'para_6' : 'para_6_ult',
intervalAttr: isNorm ? 'para_6_interval' : 'para_6_ult_interval',
};
}
/** base_para code=6 + 间隔:finish 后按间隔链式连发 */
private _schedulePara6ComboCast(entityId: EntityId, delaySec: number): void {
const combo = this._world.getComponent(entityId, ComboAttackComp);
if (combo) combo.burstTimerPending = true;
const delay = Math.max(0, delaySec);
this._clock.schedule(delay, () => {
if (!this._world.isAlive(entityId)) return;
this._executeComboCast(entityId);
});
}
private _findInterruptibleNormalAbility(entityId: EntityId): string | null {
const states = this._tickStates.get(entityId);
if (!states) return null;
for (const s of states) {
if (s.interruptibleByCast) return s.abilityId;
}
return null;
}
private _applyComboMaxAspd(entityId: EntityId): void {
const combat = this._world.getComponent(entityId, CombatComp);
const maxAspd = combat?.maxAttackSpeed ?? 20;
this._attributes.addModifier(entityId, {
attr: AttrId.AttackSpeed,
op: 'Override',
value: maxAspd > 0 ? maxAspd : 20,
source: 'combo_attack',
group: COMBO_ASPD_GROUP,
});
}
private _clearComboMaxAspd(entityId: EntityId): void {
this._attributes.removeModifiersByGroup(entityId, COMBO_ASPD_GROUP);
}
/**
* 可读普攻 OnTick 剩余时间与间隔(dynamic 时为当前攻速折算间隔)。
* @param entityId 塔实体
*/
getInterruptibleNormalTickSnapshot(entityId: EntityId): { timer: number; interval: number } | null {
const states = this._tickStates.get(entityId);
if (!states) return null;
for (let i = 0; i < states.length; i++) {
const s = states[i];
if (!s.interruptibleByCast) continue;
return {
timer: s.timer,
interval: s.interval,
};
}
return null;
}
/**
* 主动技能(大招)立即就绪:清零 Cooldown 记录并同步 OnTick timer。
* 仅改 cooldowns 不够——大招 OnTick 另有独立 timer(见 patchActiveSkillCooldown)。
*/
resetActiveSkillCooldowns(entityId: EntityId): void {
const abilities = this._world.getComponent(entityId, AbilitySetComp);
if (!abilities) return;
for (const abilityId of abilities.abilityIds) {
if (!abilityId.includes('_ult_')) continue;
if (abilities.cooldowns) {
abilities.cooldowns[abilityId] = 0;
}
const states = this._tickStates.get(entityId);
if (!states) continue;
for (const s of states) {
if (s.abilityId === abilityId) {
s.timer = 0;
}
}
}
}
}