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.
819 lines
32 KiB
819 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_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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|