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/script/battle/combat/TowerLoadoutService.ts

554 lines
22 KiB

3 weeks ago
import type { BattleSession } from 'db://assets/mx/module/battle';
import type { EntityId } from 'db://assets/mx/module/ecs';
import {
AbilitySetComp,
AtkHookComp,
CombatComp,
UnitMetaComp,
} from 'db://assets/mx/module/battle/ecs/Components';
import {
entityHeroAbilityId,
entityNormalAbilityId,
entityWeaponAbilityId,
normalAbilityId,
} from '../config/weapon1/Weapon1BattleIds';
import type { Player } from '../../game/Player';
import { TableNames } from '../../game/ConfigTableData';
import { deriveTowerSetupStats } from './TowerStatResolver';
import type { TowerLoadout } from './TowerQueryTypes';
export type { TowerLoadout };
import { SkillAbilityBuilder } from '../config/baseAtk/SkillAbilityBuilder';
2 weeks ago
import { heroNormalSkillId, heroUltSkillId, isElementHero } from '../config/BattleHeroTable';
3 weeks ago
import { dumpKnifeFinalAttack, logKnifeBaseAttack } from '../debug/AtkDebugLogCore';
import { isAtkDebugWeapon } from '../debug/AtkDebugConstants';
import { getPassiveSkillHost } from './PassiveSkillRuntime';
import { SkillAtkBuffExecutor } from './SkillAtkBuffExecutor';
import { TableAnomaly } from '../../game/TableAnomaly';
import { GemBattleEffect } from './GemBattleEffect';
3 weeks ago
/** AI 侧武将武器解析(由 AiBattleBridge start/stop 注册,避免循环 require) */
let _aiHeroWeaponResolver: ((heroId: number) => number | null) | null = null;
/**
* 注册/清除 AI 武将武器解析器。
* @param resolver 解析函数;传 null 清除
*/
export function setAiHeroWeaponResolver(resolver: ((heroId: number) => number | null) | null): void {
_aiHeroWeaponResolver = resolver;
}
/**
* 解析英雄当前战斗武器/技能宝石 id。
* 五行数字:本机读镶嵌技能宝石;非五行:本机装备 / AI 掷骰。
*/
3 weeks ago
export function resolveHeroBattleWeaponId(heroId: number, player?: Player | null): number {
2 weeks ago
const hero = gg.data.table.getTableData<ITableHero>(TableNames.Hero, heroId);
if (isElementHero(hero)) {
if (player?.isLocal) {
return GemBattleEffect.resolve(Math.floor(Number(hero!.herotype)), player).skillGemId;
}
return 0;
}
3 weeks ago
if (player && !player.isLocal) {
if (_aiHeroWeaponResolver) {
const aiId = _aiHeroWeaponResolver(heroId);
if (typeof aiId === 'number' && aiId > 0) return aiId;
}
return 0;
3 weeks ago
}
try {
const id = gg.equip?.getHeroDisplayWeaponId?.(heroId);
if (typeof id === 'number' && id > 0) return id;
} catch { /* equip 未就绪 */ }
return 0;
3 weeks ago
}
export class TowerLoadoutService {
private readonly _byEntity: { entityId: EntityId; loadout: TowerLoadout }[] = [];
private _findIndex(entityId: EntityId): number {
for (let i = 0; i < this._byEntity.length; i++) {
if (this._byEntity[i].entityId === entityId) return i;
}
return -1;
}
initEntity(entityId: EntityId, heroId: number, level: number, player: Player): void {
const hero = gg.data.table.getTableData<ITableHero>(TableNames.Hero, heroId);
if (!hero) return;
let weaponId = 0;
let weaponSkillId = 0;
if (isElementHero(hero)) {
const gem = GemBattleEffect.resolve(Math.floor(Number(hero.herotype)), player);
weaponId = gem.skillGemId;
weaponSkillId = gem.skillId;
} else {
weaponId = resolveHeroBattleWeaponId(heroId, player);
const weaponRow = weaponId > 0
? gg.data.table.getTableData<ITableWeapon>(TableNames.Weapon, weaponId)
: null;
weaponSkillId = Number(weaponRow?.act_skill) || 0;
}
3 weeks ago
let weaponSkillActive = false;
if (weaponSkillId > 0) {
const sk = gg.data.table.getTableData<ITableSkill>(TableNames.Skill, weaponSkillId);
if (!sk) {
console.log(`[TowerLoadout] 武器技能缺失 Skill.id=${weaponSkillId} weaponId=${weaponId}`);
} else {
weaponSkillActive = Number(sk.type) === 1;
}
}
4 days ago
// 五行每行自带技能数组;非五行仍按合成等级取下标
const skillLv = isElementHero(hero) ? 1 : level;
const heroSkill = this._resolveHeroSkillSlots(heroUltSkillId(hero, skillLv));
3 weeks ago
const loadout: TowerLoadout = {
weapon1Id: heroId,
level,
weaponId,
4 days ago
normalBaseAtkId: heroNormalSkillId(hero, skillLv),
3 weeks ago
overAtkType: '',
overAtkPara: '',
activeSkillBaseAtkId: heroSkill.activeId,
heroPassiveSkillId: heroSkill.passiveId,
3 weeks ago
weaponSkillId,
weaponSkillActive,
};
const idx = this._findIndex(entityId);
if (idx >= 0) this._byEntity[idx].loadout = loadout;
else this._byEntity.push({ entityId, loadout });
}
removeEntity(entityId: EntityId): void {
const idx = this._findIndex(entityId);
if (idx >= 0) this._byEntity.splice(idx, 1);
getPassiveSkillHost().unbind(entityId);
}
/** 退局清空全部装载(被动/弓由 BattleRuntime.destroy 已 clear) */
clear(): void {
this._byEntity.length = 0;
}
getLoadout(entityId: EntityId): TowerLoadout | undefined {
const idx = this._findIndex(entityId);
return idx >= 0 ? this._byEntity[idx].loadout : undefined;
}
/** @deprecated 词条替换已移除 */
replaceActiveSkillForPlayer(
_session: BattleSession,
_playerId: number,
_weapon1Id: number,
_baseAtkId: number,
): void {
/* no-op:词条表已删 */
}
/** @deprecated 词条 hook 已移除 */
appendEntryAtkHooks(_session: BattleSession, _entityId: EntityId, _entry: ITableSkill): void {
/* no-op */
}
/** @deprecated */
applySkillSpecial(_session: BattleSession, _entityId: EntityId, _entry: ITableSkill): void {
/* no-op */
}
rebuildHeroAbility(entityId: EntityId, loadout: TowerLoadout): void {
if (loadout.activeSkillBaseAtkId <= 0) return;
const row = gg.data.table.getTableData<ITableSkill>(TableNames.Skill, loadout.activeSkillBaseAtkId);
if (!row) {
console.log(`[TowerLoadout] 英雄技能缺失 Skill.id=${loadout.activeSkillBaseAtkId}`);
return;
}
const abilityId = entityHeroAbilityId(loadout.activeSkillBaseAtkId, entityId);
const def = SkillAbilityBuilder.buildHero(row, entityId);
def.id = abilityId;
if (def.costs?.[0]?.params) def.costs[0].params.abilityId = abilityId;
gg.battle.config.registerAbility(abilityId, def);
this._stampLoadoutPresentation(def, loadout, 'hero');
this.patchActiveSkillCooldown(null, entityId, abilityId, loadout.activeSkillBaseAtkId);
}
rebuildWeaponAbility(entityId: EntityId, loadout: TowerLoadout): void {
if (!loadout.weaponSkillActive || loadout.weaponSkillId <= 0) return;
const row = gg.data.table.getTableData<ITableSkill>(TableNames.Skill, loadout.weaponSkillId);
if (!row) {
console.log(`[TowerLoadout] 武器主动技能缺失 Skill.id=${loadout.weaponSkillId}`);
return;
}
const abilityId = entityWeaponAbilityId(loadout.weaponSkillId, entityId);
const def = SkillAbilityBuilder.buildWeapon(row, entityId);
def.id = abilityId;
if (def.costs?.[0]?.params) def.costs[0].params.abilityId = abilityId;
gg.battle.config.registerAbility(abilityId, def);
this._stampLoadoutPresentation(def, loadout, 'weapon');
this.patchActiveSkillCooldown(null, entityId, abilityId, loadout.weaponSkillId);
}
/**
* 被动 result 10:覆盖宿主普攻 atk_type / atk_para 并重建 Ability。
* 不改共享 Skill 表,按实体隔离,避免同技能其他塔串台。
* @param session 战斗会话
* @param entityId 触发宿主
* @param atkType 新 atk_type
* @param atkPara 新 atk_para
* @param sourceSkillId 被动 Skill.id
*/
applyNormalAtkOverride(session: BattleSession, entityId: EntityId, atkType: string, atkPara: string, sourceSkillId: number): void {
const loadout = this.getLoadout(entityId);
if (!loadout) {
console.log(`[TowerLoadout] result 10 无装载 skill=${sourceSkillId} entity=${entityId}`);
return;
}
if (!(loadout.normalBaseAtkId > 0)) {
console.log(`[TowerLoadout] result 10 无普攻 skill=${sourceSkillId} entity=${entityId}`);
return;
}
loadout.overAtkType = atkType;
loadout.overAtkPara = atkPara;
const nextId = this._registerNormalAbility(entityId, loadout);
if (!nextId) return;
const set = session.world.getComponent(entityId, AbilitySetComp);
if (!set) {
console.log(`[TowerLoadout] result 10 无 AbilitySet skill=${sourceSkillId} entity=${entityId}`);
return;
}
this._replaceNormalAbilityId(set, loadout.normalBaseAtkId, nextId);
session.abilities.bindEntity(entityId, { preserveRuntime: true });
}
/**
* 按装载(含覆盖)注册普攻 Ability。
* @param entityId 塔实体
* @param loadout 三槽装载
* @returns Ability id;普攻缺失时为空
*/
private _registerNormalAbility(entityId: EntityId, loadout: TowerLoadout): string {
const skillId = loadout.normalBaseAtkId;
const row = this._normalSkillRow(loadout);
if (!row) {
console.log(`[TowerLoadout] 普攻缺失 Skill.id=${skillId}`);
return '';
}
const abilityId = loadout.overAtkType
? entityNormalAbilityId(skillId, entityId)
: normalAbilityId(skillId);
const def = SkillAbilityBuilder.buildNormal(row, abilityId);
this._stampLoadoutPresentation(def, loadout, 'normal');
gg.battle.config.registerAbility(abilityId, def);
return abilityId;
}
/**
* 普攻 Skill 行;有覆盖时拷贝并改 atk_type / atk_para。
* @param loadout 三槽装载
*/
private _normalSkillRow(loadout: TowerLoadout): ITableSkill | null {
const row = gg.data.table.getTableData<ITableSkill>(TableNames.Skill, loadout.normalBaseAtkId);
if (!row) return null;
if (!loadout.overAtkType) return row;
return Object.assign({}, row, {
atk_type: loadout.overAtkType,
atk_para: loadout.overAtkPara,
});
}
/**
* 把实体普攻 Ability id 换成覆盖后的实体 id。
* @param set 技能集
* @param skillId 普攻 Skill.id
* @param nextId 新 Ability id
*/
private _replaceNormalAbilityId(set: AbilitySetComp, skillId: number, nextId: string): void {
const shared = normalAbilityId(skillId);
const prefix = `ability_norm_${skillId}_e`;
let found = false;
for (let i = 0; i < set.abilityIds.length; i++) {
const id = set.abilityIds[i];
if (id === shared || id === nextId || id.indexOf(prefix) === 0) {
set.abilityIds[i] = nextId;
found = true;
}
}
if (!found) {
console.log(`[TowerLoadout] result 10 未找到普攻 Ability skill=${skillId}`);
set.abilityIds.unshift(nextId);
}
const prevCd = set.cooldowns[shared];
if (prevCd != null && set.cooldowns[nextId] == null) {
set.cooldowns[nextId] = prevCd;
}
}
/** 写入表现对接字段:heroId / weaponId / composeLevel / target 快照由 cue 时补 */
private _stampLoadoutPresentation(
def: import('db://assets/mx/module/battle').AbilityDef,
loadout: TowerLoadout,
skillKind: 'normal' | 'hero' | 'weapon',
): void {
for (const ev of def.timeline) {
for (const fx of ev.effects) {
if (fx.type !== 'PlayPresentation' || !fx.params) continue;
fx.params.heroId = loadout.weapon1Id;
fx.params.weaponId = loadout.weaponId;
fx.params.composeLevel = loadout.level;
fx.params.skillKind = skillKind;
}
}
}
/** 按 Skill.cd 写 OnTick / Cooldown(不再叠局外角色冷却) */
patchActiveSkillCooldown(
session: BattleSession | null,
entityId: EntityId,
abilityId: string,
skillId: number,
): void {
if (skillId <= 0 || !abilityId) return;
const skillRow = gg.data.table.getTableData<ITableSkill>(TableNames.Skill, skillId);
const baseCd = skillRow?.cd ?? 0;
const def = gg.battle.config.getAbility(abilityId);
if (!def) return;
if (baseCd <= 0) {
TableAnomaly.warn('Skill', skillId, 'cd', '主动技能 cd≤0,不编造连放间隔', baseCd);
def.costs = [];
return;
}
let cd = baseCd;
if (session) cd = session.attributes.getFinalCooldown(entityId, cd);
if (def.triggers[0]) def.triggers[0].interval = cd;
def.costs = [{ type: 'Cooldown', params: { cd, abilityId } }];
}
/** @deprecated 词条改参已移除 */
rebuildUltAbility(entityId: EntityId, loadout: TowerLoadout): void {
this.rebuildHeroAbility(entityId, loadout);
}
/** @deprecated */
buildEntityUltAbility(session: BattleSession, entityId: EntityId, _entries: readonly ITableSkill[]): void {
const loadout = this.getLoadout(entityId);
if (!loadout) return;
this.rebuildHeroAbility(entityId, loadout);
this.rebuildWeaponAbility(entityId, loadout);
session.abilities.bindEntity(entityId, { preserveRuntime: true });
}
/** @deprecated */
reapplySkillChanges(session: BattleSession, entityId: EntityId, entries: readonly ITableSkill[]): void {
this.buildEntityUltAbility(session, entityId, entries);
}
applyAbilities(session: BattleSession, entityId: EntityId, loadout: TowerLoadout): void {
const world = session.world;
if (!world.isAlive(entityId)) return;
if (loadout.activeSkillBaseAtkId > 0) this.rebuildHeroAbility(entityId, loadout);
if (loadout.weaponSkillActive) this.rebuildWeaponAbility(entityId, loadout);
const abilityIds: string[] = [];
if (loadout.normalBaseAtkId > 0) {
const nid = this._registerNormalAbility(entityId, loadout);
if (nid) abilityIds.push(nid);
}
if (loadout.activeSkillBaseAtkId > 0) {
abilityIds.push(entityHeroAbilityId(loadout.activeSkillBaseAtkId, entityId));
}
if (loadout.weaponSkillActive && loadout.weaponSkillId > 0) {
abilityIds.push(entityWeaponAbilityId(loadout.weaponSkillId, entityId));
}
let set = world.getComponent(entityId, AbilitySetComp);
const isRefresh = !!set;
const prevCd = set ? { ...set.cooldowns } : {};
if (!set) {
set = new AbilitySetComp(abilityIds, {});
world.addComponent(entityId, set);
} else {
set.abilityIds = abilityIds;
const nextCd: Record<string, number> = {};
for (const id of abilityIds) {
if (prevCd[id] != null) nextCd[id] = prevCd[id];
}
set.cooldowns = nextCd;
}
if (loadout.activeSkillBaseAtkId > 0) {
this.patchActiveSkillCooldown(
session, entityId,
entityHeroAbilityId(loadout.activeSkillBaseAtkId, entityId),
loadout.activeSkillBaseAtkId,
);
}
if (loadout.weaponSkillActive && loadout.weaponSkillId > 0) {
this.patchActiveSkillCooldown(
session, entityId,
entityWeaponAbilityId(loadout.weaponSkillId, entityId),
loadout.weaponSkillId,
);
}
session.abilities.bindEntity(entityId, { preserveRuntime: isRefresh });
// 被动:武器 type=2
const passives = getPassiveSkillHost();
passives.bindAtkOverride(this);
passives.bindFromLoadout(session, entityId, loadout);
}
setupCombat(session: BattleSession, entityId: EntityId, weapon1Id: number, level: number, player: Player): void {
const hero = gg.data.table.getTableData<ITableHero>(TableNames.Hero, weapon1Id);
if (!hero) return;
const world = session.world;
const isRefresh = !!world.getComponent(entityId, AbilitySetComp);
const battleWeaponId = resolveHeroBattleWeaponId(weapon1Id, player);
const setup = deriveTowerSetupStats(hero, level, battleWeaponId);
const lv = setup.effectiveLevel;
if (world.getComponent(entityId, CombatComp)) {
world.removeComponent(entityId, CombatComp);
}
world.addComponent(entityId, new CombatComp(
setup.attack,
setup.attackSpeed,
setup.range,
setup.critRate,
setup.critDamage,
setup.maxAttackSpeed,
));
const meta = world.getComponent(entityId, UnitMetaComp);
if (meta) meta.level = lv;
session.attributes.syncCombatToAttributes(entityId);
SkillAtkBuffExecutor.clearRangeModifiers(session, entityId);
this.initEntity(entityId, weapon1Id, lv, player);
const loadout = this.getLoadout(entityId)!;
if (isElementHero(hero)) {
const gem = GemBattleEffect.resolve(Math.floor(Number(hero.herotype)), player);
GemBattleEffect.apply(session, entityId, gem);
} else {
GemBattleEffect.clear(session, entityId);
}
3 weeks ago
this.applyAbilities(session, entityId, loadout);
if (!isRefresh && loadout.weaponSkillId > 0 && !loadout.weaponSkillActive) {
const sk = gg.data.table.getTableData<ITableSkill>(TableNames.Skill, loadout.weaponSkillId);
if (sk) {
SkillAtkBuffExecutor.applyLoadoutBuffs(session, entityId, sk.atk_buff1 ?? '', sk.atk_buff2 ?? '');
}
}
this._applyWeaponDistDmg(session, entityId, loadout);
if (isAtkDebugWeapon(weapon1Id)) {
logKnifeBaseAttack({
entityId,
weapon1Id,
composeLevel: level,
effectiveLevel: setup.effectiveLevel,
heroAtk: hero.atk,
weaponAtk: Math.max(0, setup.attack - hero.atk),
3 weeks ago
combatAttack: setup.attack,
});
dumpKnifeFinalAttack(session, entityId, '部署', weapon1Id);
}
if (!isRefresh) {
const set = world.getComponent(entityId, AbilitySetComp);
if (set) {
if (loadout.activeSkillBaseAtkId > 0) {
set.cooldowns[entityHeroAbilityId(loadout.activeSkillBaseAtkId, entityId)] = 0;
}
if (loadout.weaponSkillActive && loadout.weaponSkillId > 0) {
set.cooldowns[entityWeaponAbilityId(loadout.weaponSkillId, entityId)] = 0;
}
}
}
}
resetAtkHooks(session: BattleSession, entityId: EntityId): void {
const world = session.world;
let hook = world.getComponent(entityId, AtkHookComp);
if (!hook) {
hook = new AtkHookComp();
world.addComponent(entityId, hook);
} else {
hook.before = [];
hook.after = [];
hook.ultBefore = [];
hook.ultAfter = [];
hook.ultCanCrit = false;
hook.specials = [];
hook.distPerPx = 0;
hook.distAddWan = 0;
hook.distMaxWan = 0;
}
}
/**
* `Hero.skill_id` 按 Skill.type 拆主动/被动槽。
* @param skillId Hero.skill_id
*/
private _resolveHeroSkillSlots(skillId: number): { activeId: number; passiveId: number } {
const id = Math.floor(Number(skillId) || 0);
if (!(id > 0)) return { activeId: 0, passiveId: 0 };
const sk = gg.data.table.getTableData<ITableSkill>(TableNames.Skill, id);
if (!sk) {
console.log(`[TowerLoadout] 英雄技能缺失 Skill.id=${id}`);
return { activeId: 0, passiveId: 0 };
}
const typ = Math.floor(Number(sk.type) || 0);
if (typ === 1) return { activeId: id, passiveId: 0 };
if (typ === 2) return { activeId: 0, passiveId: id };
console.log(`[TowerLoadout] 英雄技能 type 非主动/被动,仍按主动挂`, id, typ);
return { activeId: id, passiveId: 0 };
}
3 weeks ago
/**
* 武器被动 Skill.skill 类型8:写入 AtkHookComp,普攻命中按距离加伤。
* @param session 战斗会话
* @param entityId 塔实体
* @param loadout 三槽装载
*/
private _applyWeaponDistDmg(session: BattleSession, entityId: EntityId, loadout: TowerLoadout): void {
const world = session.world;
let hook = world.getComponent(entityId, AtkHookComp);
if (!hook) {
hook = new AtkHookComp();
world.addComponent(entityId, hook);
}
hook.distPerPx = 0;
hook.distAddWan = 0;
hook.distMaxWan = 0;
if (!(loadout.weaponSkillId > 0) || loadout.weaponSkillActive) return;
const sk = gg.data.table.getTableData<ITableSkill>(TableNames.Skill, loadout.weaponSkillId);
if (!sk) return;
const parts = String(sk.skill ?? '').trim().split(',').map(Number);
if ((parts[0] ?? 0) !== 8) return;
const perPx = parts[1];
const addWan = parts[2];
const maxWan = parts[3];
if (!(perPx > 0) || !(addWan > 0) || !(maxWan > 0)) {
console.log('[TowerLoadout] skill 类型8参数无效', sk.id, sk.skill);
return;
}
hook.distPerPx = perPx;
hook.distAddWan = addWan;
hook.distMaxWan = maxWan;
}
}
let _instance: TowerLoadoutService | null = null;
export function getTowerLoadoutService(): TowerLoadoutService {
if (!_instance) _instance = new TowerLoadoutService();
return _instance;
}
export function resetTowerLoadoutServiceForTests(): void {
_instance = null;
}