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(); private _eventListeners = new Map>(); /** 实体 → 施法结束时刻(秒,battle clock);此时刻前抑制该实体 interruptibleByCast 的 OnTick */ private _castingUntil = new Map(); /** 表现 deferred 未 finish 前锁定施法(冻结 interruptibleByCast OnTick) */ private _presentationLocked = createMembershipSet(); /** 全局施法实例计数(写入 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_4') || 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; } } } } }