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/MonsterSkillHost.ts

2033 lines
75 KiB

import { Node, UIOpacity, UITransform, Vec3, sp } from 'cc';
import { SkillTransformDriver } from '../skill/SkillTransformDriver';
import {
AbilitySetComp,
BattleEventType,
BattleTeamId,
HealthComp,
MovementComp,
TeamComp,
TransformComp,
UnitMetaComp,
type BattleEvent,
} from 'db://assets/mx/module/battle';
import { createAbilityContext } from 'db://assets/mx/module/battle/combat/AbilityContext';
import type { EntityId } from 'db://assets/mx/module/ecs';
import { GBundle, GPath } from '../../game/ConfigRes';
import { TableNames } from '../../game/ConfigTableData';
import { parseLegacyMonsterId, parseMonsterUnitDefId } from '../config/MonsterHpCalculator';
import { MonsterDataManager } from '../../manager/MonsterDataManager';
import { SkillAbilityBuilder } from '../config/baseAtk/SkillAbilityBuilder';
import { entityMonsterAbilityId, isMonsterAbilityId } from '../config/weapon1/Weapon1BattleIds';
import { BattleMapSpec } from '../map/BattleMapSpec';
import { BattleSpinePlayback } from '../presentation/BattleSpinePlayback';
import type { ProjectBattleHandle } from '../runtime/BattleRuntime';
import { findGridIndexForEntity } from '../runtime/DeployEntityQuery';
import { buildSkillPresentationContext } from '../skill/SkillPresentationBridge';
import { SkillPresentationEnv } from '../skill/SkillPresentationEnv';
import type { SkillPresentationContext } from '../skill/SkillPresentationTypes';
import { BATTLE_MONSTER_CCCLASS, type IBattleMonsterView } from '../view/IBattleMonsterView';
import { getMonsterStatusText } from '../view/MonsterStatusText';
import { pickSealablePutTiles, sealPlaceableTiles } from './SkillAtkBuffBoardBridge';
import { getTowerRuinHost } from './TowerRuinHost';
import { SkillAtkBuffExecutor } from './SkillAtkBuffExecutor';
/** Skill.skill 类型 9:干扰迷雾 */
const EXTRA_FOG = 9;
/** 迷雾预制体(battleHero/prefab/skill) */
const FOG_PREFAB = '干扰迷雾';
/** 迷雾循环动画 */
const FOG_ANIM = '技能效果';
/** 持续结束后渐隐秒 */
const FOG_FADE_SEC = 0.3;
/** atk_type=15 隐藏:全屏紫雾遮挡 + 怪身,只留乌云 */
const FOG_LOCAL_HIDE_SLOTS = ['遮挡烟雾', '遮挡烟雾2', '身体', '武器', '六耳/影子'];
/** atk_type=15 局部迷雾节点缩放(Spine 视觉不受 UITransform ContentSize 约束) */
const FOG_LOCAL_SCALE_X = 0.2;
/** atk_type=15 局部迷雾节点缩放 Y */
const FOG_LOCAL_SCALE_Y = 0.4;
/** Skill.atk_buff1 类型 9:封可放置格为可开挖 */
const BUFF_SEAL = 9;
/** 千里眼 Boss 技能预制体(battleHero/prefab/skill) */
const EYE_PREFAB = '千里眼技能';
/** 光束子节点 */
const EYE_BEAM_NODE = '光束';
/** 落点特效子节点 */
const EYE_HIT_NODE = '目标点效果';
/** 无技能动画时长时特效最短保持秒(与封格 fill 同量级) */
const EYE_HOLD_SEC = 1.2;
/** Skill.atk_buff1 类型 12:损毁基础字 */
const BUFF_DESTROY = 12;
/** 损毁预制体(battleHero/prefab/skill) */
const DESTROY_PREFAB = '损毁文字';
/** Skill.atk_buff1 类型 6:降雨降攻速 */
const BUFF_RAIN = 6;
/** 降雨预制体(battleHero/prefab/skill);敖广用,奔波儿灞(skill 类型9)只用降攻速不挂此特效 */
const RAIN_PREFAB = '降雨';
/** 奔波儿灞喷毒葫芦(battleHero/prefab/skill) */
const GOURD_PREFAB = '葫芦';
/** 葫芦 Spine 节点 */
const GOURD_ANIM_NODE = '葫芦动画';
/** 葫芦释放动画 */
const GOURD_ANIM = '技能葫芦';
/** 葫芦待机角(预制体 euler.z) */
const GOURD_REST_DEG = 45;
/** 葫芦相对皮肤根的待机坐标(预制体 lpos) */
const GOURD_REST_X = 24.573;
/** 葫芦相对皮肤根的待机坐标 Y */
const GOURD_REST_Y = 30.123;
/** 葫芦转向/回正秒 */
const GOURD_TURN_SEC = 0.12;
/** 降雨落点相对格子中心上移(像素) */
const RAIN_ABOVE_PX = BattleMapSpec.CELL_SIZE * 0.5;
/** Skill.skill 类型 5:吞噬本方小兵 */
const EXTRA_DEVOUR = 5;
/** Skill.atk_buff1 类型 3:移速 */
const BUFF_SPEED = 3;
/** 施法表现等待 */
interface PendingPresent {
/** 施法者 */
entity: EntityId;
/** 表现上下文 */
ctx: SkillPresentationContext;
/** 剩余逻辑秒 */
wait: number;
/** 本次是否锁了施法者位移 */
moveLocked: boolean;
}
/** 干扰迷雾实例 */
interface FogFx {
/** 迷雾节点 */
node: Node;
/** 透明度 */
opacity: UIOpacity;
/** 渐显剩余秒 */
appear: number;
/** 渐显总秒;0 表示已满显 */
appearTotal: number;
/** 保持剩余秒 */
hold: number;
/** 渐隐剩余秒;<0 表示尚未开始渐隐 */
fade: number;
/** atk_type=15:局部盖主目标,需藏全屏紫雾槽 */
localCover: boolean;
}
/** 损毁文字实例 */
interface DestroyFx {
/** 特效节点 */
node: Node;
/** 目标实体 */
target: EntityId;
/** 剩余逻辑秒 */
wait: number;
}
/** 降雨实例 */
interface RainFx {
/** 特效节点 */
node: Node;
/** 保持剩余秒 */
hold: number;
}
/** 奔波儿灞葫芦 */
interface GourdFx {
/** 施法者 */
entity: EntityId;
/** 葫芦根 */
root: Node;
/** 回正角度 */
restDeg: number;
/** 播完剩余秒 */
wait: number;
/** 已开始回正 */
returning: boolean;
}
/** 千里眼技能特效 */
interface EyeFx {
/** 施法者 */
caster: EntityId;
/** 根节点 */
node: Node;
/** 剩余逻辑秒 */
hold: number;
}
/** 被吞噬小兵 */
interface DevourVictim {
/** 实体 */
entity: EntityId;
/** 吸入起点 X */
fromX: number;
/** 吸入起点 Y */
fromY: number;
/** 吸入起点缩放 X */
scaleX: number;
/** 吸入起点缩放 Y */
scaleY: number;
}
/** 一次吞噬 */
interface DevourRun {
/** 施法者 */
caster: EntityId;
/** 被吸小兵 */
victims: DevourVictim[];
/** 已过逻辑秒 */
elapsed: number;
/** 吸入时长(技能动画秒) */
duration: number;
/** 每只加成万分比 */
bonusWan: number;
}
/** 友军加速特效计时 */
interface AllySpeedFx {
/** 目标实体 */
entity: EntityId;
/** 剩余秒 */
wait: number;
}
let _host: MonsterSkillHost | null = null;
/** 怪物技能 Host(单局复用) */
export function getMonsterSkillHost(): MonsterSkillHost {
if (!_host) _host = new MonsterSkillHost();
return _host;
}
/**
* 出怪绑定 Monster.skill → Ability,承接 monsterSkill cue 播释放动画。
* extra 9:起播时挂干扰迷雾并渐显(无 atk_type=半屏;atk_type=15=主目标格);同时降攻速。
* atk_buff1 类型 12:技能动画结束后在锁定基础字格播损毁文字并使其失效。
* atk_buff1 类型 6:技能动画结束后降攻速;无 skill 类型 9 时再挂降雨(敖广)。
* 奔波儿灞:播「技能」同时出池 `葫芦` 挂怪物(预制体默认偏移),播「技能葫芦」并朝向主目标格;同时渐显干扰迷雾,播完回 45° 后回池。
* extra 5:释放同时吸入本方周围小兵,动画结束吞噬并加血上限。
* target_stage=2:技能动画结束后给射程内同侧友军怪上 atk_buff(太白移速+回血,含自身)。
* atk_buff1 类型 9:施法起播时出千里眼技能瞄向目标格并封为可开挖。
*/
export class MonsterSkillHost {
private _battle: ProjectBattleHandle | null = null;
private _bound = false;
private readonly _pending: PendingPresent[] = [];
private readonly _fogs: FogFx[] = [];
/** 损毁文字等待 */
private readonly _destroys: DestroyFx[] = [];
/** 降雨等待 */
private readonly _rains: RainFx[] = [];
/** 奔波儿灞葫芦 */
private readonly _gourds: GourdFx[] = [];
/** 葫芦瞄准 local scratch */
private readonly _gourdLocal = new Vec3();
/** 千里眼技能特效 */
private readonly _eyes: EyeFx[] = [];
/** 千里眼瞄准 local scratch */
private readonly _eyeLocal = new Vec3();
/** 吞噬吸入 */
private readonly _devours: DevourRun[] = [];
/** 半屏定位 scratch */
private readonly _fogLocal = new Vec3();
/** 半屏世界坐标 scratch */
private readonly _fogWorld = new Vec3();
/** 吞噬扫描施法者 */
private _devourScanCaster: EntityId = 0;
/** 吞噬扫描射程像素 */
private _devourScanRangePx = 0;
/** 吞噬扫描命中 */
private readonly _devourScanIds: EntityId[] = [];
/** 友军加速特效 */
private readonly _allySpeeds: AllySpeedFx[] = [];
/** 友军 buff 扫描施法者 */
private _allyScanCaster: EntityId = 0;
/** 友军 buff 扫描射程像素 */
private _allyScanRangePx = 0;
/** 友军 buff 扫描命中 */
private readonly _allyScanIds: EntityId[] = [];
/** 绑定本局:监听出怪与表现 cue */
bind(battle: ProjectBattleHandle): void {
this.unbind();
this._battle = battle;
battle.session.events.on(BattleEventType.UnitSpawned, this._onBattleEvent);
battle.session.events.on(BattleEventType.PresentationCue, this._onBattleEvent);
battle.session.events.on(BattleEventType.EntityKilled, this._onBattleEvent);
battle.session.events.on(BattleEventType.UnitDespawned, this._onBattleEvent);
this._bound = true;
battle.session.world.forEachAlive(this._tryBind);
}
/** 解除绑定并释放施法锁 */
unbind(): void {
const battle = this._battle;
if (battle && this._bound) {
battle.session.events.off(BattleEventType.UnitSpawned, this._onBattleEvent);
battle.session.events.off(BattleEventType.PresentationCue, this._onBattleEvent);
battle.session.events.off(BattleEventType.EntityKilled, this._onBattleEvent);
battle.session.events.off(BattleEventType.UnitDespawned, this._onBattleEvent);
}
this._bound = false;
this._cancelAll('host_unbind');
this._recycleAllFogs();
this._recycleAllDestroys();
this._recycleAllRains();
this._recycleAllGourds();
this._recycleAllEyes();
this._abortAllDevours();
this._clearAllAllySpeeds();
this._battle = null;
}
/**
* 还魂丹复活:取消未结束技能表现并解锁施法锁。
* @param entityId 行军怪实体
*/
releasePassiveReviveHold(entityId: EntityId): void {
this._cancelEntity(entityId, 'passive_revive');
const battle = this._battle;
if (battle) {
battle.session.abilities.setPresentationLocked(entityId, false);
}
}
/**
* 推进技能动画等待、迷雾、损毁、降雨与吞噬吸入。
* @param dt 战斗缩放秒
*/
tick(dt: number): void {
if (!(dt > 0)) return;
this._tickDevours(dt);
if (this._pending.length > 0) {
for (let i = this._pending.length - 1; i >= 0; i--) {
const item = this._pending[i];
item.wait -= dt;
if (item.wait > 0 && item.ctx.isAlive) continue;
this._pending.splice(i, 1);
this._finishPresent(item);
}
}
this._tickFogs(dt);
this._tickDestroys(dt);
this._tickRains(dt);
this._tickGourds(dt);
this._tickEyes(dt);
this._tickAllySpeeds(dt);
}
private _onBattleEvent = (event: BattleEvent): void => {
if (event.type === BattleEventType.UnitSpawned) {
this._tryBind(event.entity);
return;
}
if (event.type === BattleEventType.PresentationCue) {
this._present(event);
return;
}
if (event.type === BattleEventType.EntityKilled) {
this._cancelEntity(event.victim, 'caster_dead');
return;
}
if (event.type === BattleEventType.UnitDespawned) {
this._cancelEntity(event.entity, 'caster_despawn');
}
};
/**
* 按 Monster.skill 注册并绑定 Ability。
* @param entityId 出怪实体
*/
private _tryBind = (entityId: EntityId): void => {
const battle = this._battle;
if (!battle) return;
const world = battle.session.world;
if (!world.isAlive(entityId)) return;
const meta = world.getComponent(entityId, UnitMetaComp);
if (!meta?.unitDefId) return;
const parsed = parseMonsterUnitDefId(meta.unitDefId);
const monsterId = parsed?.monsterId ?? parseLegacyMonsterId(meta.unitDefId);
if (!(monsterId > 0)) return;
const monster = gg.data.table.getTableData<ITableMonster>(TableNames.Monster, monsterId);
if (!monster) {
console.log('[MonsterSkillHost] Monster 缺失', monsterId);
return;
}
const skillId = Math.floor(Number(monster.skill) || 0);
if (!(skillId > 0)) return;
const row = gg.data.table.getTableData<ITableSkill>(TableNames.Skill, skillId);
if (!row) {
console.log('[MonsterSkillHost] Skill 缺失', skillId, 'monster', monsterId);
return;
}
const skillType = Number(row.type);
if (skillType === 2) {
console.log('[MonsterSkillHost] 被动技能本切片不绑定', monsterId, skillId);
return;
}
if (skillType !== 1) {
console.log('[MonsterSkillHost] Skill.type 非主动(1),仍按主动绑定播动画', skillId, skillType);
}
const abilityId = entityMonsterAbilityId(row.id, entityId);
const set = world.getComponent(entityId, AbilitySetComp);
if (set && set.abilityIds.indexOf(abilityId) >= 0) return;
const config = battle.session.config;
if (!config.registerAbility) {
console.log('[MonsterSkillHost] config 不支持 registerAbility', abilityId);
return;
}
const startOnCooldown = MonsterDataManager.isBossType(monster.type);
config.registerAbility(abilityId, SkillAbilityBuilder.buildMonster(row, entityId, startOnCooldown));
if (!set) {
world.addComponent(entityId, new AbilitySetComp([abilityId]));
} else {
set.abilityIds.push(abilityId);
}
battle.session.abilities.bindEntity(entityId, { preserveRuntime: !!set });
};
/**
* 播 Skill.animation;动画结束后 finish 解锁。
* @param event PresentationCue
*/
private _present(event: Extract<BattleEvent, { type: BattleEventType.PresentationCue }>): void {
const isMonster = isMonsterAbilityId(event.abilityId)
|| event.params?.skillKind === 'monster'
|| event.cueId === 'monsterSkill';
if (!isMonster) return;
const battle = this._battle;
if (!battle) {
console.log('[MonsterSkillHost] 未绑定,丢弃怪物技能 cue', event.abilityId);
return;
}
// 被动打断等已 cancel 同次 cast:无 deferred pending,不播技能动画
if (!battle.session.presentation.getPending(event.castInstanceId)) {
return;
}
const ctx = buildSkillPresentationContext(battle.session, {
caster: event.caster,
abilityId: event.abilityId,
cueId: event.cueId,
castInstanceId: event.castInstanceId,
targetEntityIds: event.targetEntityIds,
params: event.params,
});
if (!ctx.isAlive) {
ctx.cancel('caster_dead');
return;
}
const animName = String(event.params?.animation ?? '').trim();
if (!animName) {
console.log('[MonsterSkillHost] Skill.animation 为空', event.abilityId);
ctx.finish();
return;
}
const node = SkillPresentationEnv.getMonsterNode(event.caster);
const view = node?.isValid
? node.getComponent(BATTLE_MONSTER_CCCLASS) as IBattleMonsterView | null
: null;
const sk = view?.anim;
if (!node?.isValid || !view || !sk?.isValid) {
console.log('[MonsterSkillHost] 怪物节点未就绪', event.caster, event.abilityId);
ctx.finish();
return;
}
if (sk.animation === '死亡') {
ctx.finish();
return;
}
if (BattleSpinePlayback.getAnimDuration(sk, animName) == null) {
console.log('[MonsterSkillHost] 骨骼无技能动画', animName, node.name, event.abilityId);
ctx.finish();
return;
}
const delay = BattleSpinePlayback.resolveLogicalCompleteDelay(sk, animName, 1);
// Boss/怪物主动技能:动画倍速固定 1(不跟攻速)
view.playSpineAnim?.(animName, false);
this._playGourdIfNeeded(event.caster, ctx);
const wait = delay != null && delay > 0 ? delay : 0;
this._spawnFogIfNeeded(ctx, wait > 0 ? wait : FOG_FADE_SEC);
// 迷雾起播即降攻速,避免动画结束前点面板看不到
if (Math.floor(Number(ctx.params.skillType ?? 0) || 0) === EXTRA_FOG) {
this._spawnRainIfNeeded(ctx);
}
getMonsterStatusText().play(event.caster, '释放技能');
this._beginDevour(ctx, wait);
this._beginEyeIfNeeded(ctx, wait);
if (!(wait > 0)) {
this._finishPresent({ entity: event.caster, ctx, wait: 0, moveLocked: false });
return;
}
this._lockCasterMove(event.caster);
this._pending.push({ entity: event.caster, ctx, wait, moveLocked: true });
}
/**
* 动画结束:回走路并解锁施法。
* @param item 等待项
*/
private _finishPresent(item: PendingPresent): void {
const ctx = item.ctx;
if (item.moveLocked) this._unlockCasterMove(item.entity);
if (!ctx.isAlive) {
this._abortDevour(item.entity);
ctx.cancel('caster_dead');
return;
}
const node = SkillPresentationEnv.getMonsterNode(item.entity);
const view = node?.isValid
? node.getComponent(BATTLE_MONSTER_CCCLASS) as IBattleMonsterView | null
: null;
if (view?.playWalk && view.anim?.isValid && view.anim.animation !== '死亡') {
view.playWalk();
}
this._consumeDevour(item.entity);
this._spawnDestroyIfNeeded(ctx);
if (Math.floor(Number(ctx.params.skillType ?? 0) || 0) !== EXTRA_FOG) {
this._spawnRainIfNeeded(ctx);
}
this._buffAlliesIfNeeded(ctx);
while (ctx.isAlive && ctx.hit()) { /* 召唤 / atk_buff */ }
ctx.finish();
}
/**
* 取消指定实体未结束的表现。
* @param entityId 施法者
* @param reason 原因
*/
private _cancelEntity(entityId: EntityId, reason: string): void {
this._abortDevour(entityId);
this._recycleGourdOf(entityId);
this._recycleEyesOf(entityId);
for (let i = this._pending.length - 1; i >= 0; i--) {
if (this._pending[i].entity !== entityId) continue;
const item = this._pending[i];
this._pending.splice(i, 1);
if (item.moveLocked) this._unlockCasterMove(entityId);
if (item.ctx.isAlive) item.ctx.cancel(reason);
}
}
/**
* 取消全部未结束表现。
* @param reason 原因
*/
private _cancelAll(reason: string): void {
for (let i = 0; i < this._pending.length; i++) {
const item = this._pending[i];
if (item.moveLocked) this._unlockCasterMove(item.entity);
if (item.ctx.isAlive) item.ctx.cancel(reason);
}
this._pending.length = 0;
this._abortAllDevours();
this._recycleAllGourds();
this._recycleAllEyes();
}
/**
* 施法期间停步。门神顶推中不重新上锁,避免 Boss 技能把怪钉死。
* @param entityId 施法者
*/
private _lockCasterMove(entityId: EntityId): void {
const move = this._battle?.session.world.getComponent(entityId, MovementComp);
if (!move || move.bajiaoBlowRetreating) return;
move.presentLocked = true;
}
/**
* 施法结束恢复走路。
* @param entityId 施法者
*/
private _unlockCasterMove(entityId: EntityId): void {
const move = this._battle?.session.world.getComponent(entityId, MovementComp);
if (move) move.presentLocked = false;
}
/**
* skill 类型 9:出池葫芦挂怪物,用预制体默认偏移,朝向主目标格并播「技能葫芦」。
* @param entity 施法者
* @param ctx 本次施法
*/
private _playGourdIfNeeded(entity: EntityId, ctx: SkillPresentationContext): void {
const raw = String(ctx.params.extraSkill ?? ctx.params.skillRaw ?? '');
const head = Math.floor(Number(raw.split(',')[0]) || 0);
if (head !== EXTRA_FOG) return;
this._recycleGourdOf(entity);
const parent = SkillPresentationEnv.getMonsterNode(entity);
if (!parent?.isValid) {
console.log('[MonsterSkillHost] 葫芦无怪物挂点', entity);
return;
}
let root: Node | null = null;
try {
root = gg.res.getNode(GPath.HeroSkillPrefab(), GOURD_PREFAB, GBundle.BattleHero);
} catch (err) {
console.log('[MonsterSkillHost] getNode 失败', GOURD_PREFAB, String(err));
return;
}
if (!root?.isValid) {
console.log('[MonsterSkillHost] 预制体未加载', GOURD_PREFAB);
return;
}
root.layer = parent.layer;
root.parent = parent;
root.setSiblingIndex(parent.children.length - 1);
root.setPosition(GOURD_REST_X, GOURD_REST_Y, 0);
root.setScale(1, 1, 1);
root.angle = GOURD_REST_DEG;
root.active = true;
const animNode = root.getChildByName(GOURD_ANIM_NODE);
const sk = animNode?.getComponent(sp.Skeleton);
if (!animNode?.isValid || !sk?.isValid) {
console.log('[MonsterSkillHost] 葫芦缺少动画节点', entity);
this._recycleGourdNode(root, GOURD_REST_DEG);
return;
}
if (BattleSpinePlayback.getAnimDuration(sk, GOURD_ANIM) == null) {
console.log('[MonsterSkillHost] 葫芦缺少动画', GOURD_ANIM, entity);
this._recycleGourdNode(root, GOURD_REST_DEG);
return;
}
const aimDeg = this._gourdAimDeg(root, ctx.targets[0] ?? 0);
if (aimDeg == null) {
console.log('[MonsterSkillHost] 葫芦无目标朝向', entity);
}
SkillTransformDriver.cancelNode(root);
if (aimDeg != null) {
SkillTransformDriver.animate({
node: root,
duration: GOURD_TURN_SEC,
fromAngle: GOURD_REST_DEG,
toAngle: aimDeg,
});
}
if (!BattleSpinePlayback.play(sk, GOURD_ANIM, { loop: false })) {
console.log('[MonsterSkillHost] 葫芦动画起播失败', GOURD_ANIM, entity);
this._recycleGourdNode(root, GOURD_REST_DEG);
return;
}
const delay = BattleSpinePlayback.resolveLogicalCompleteDelay(sk, GOURD_ANIM, 1);
this._gourds.push({
entity,
root,
restDeg: GOURD_REST_DEG,
wait: delay != null && delay > 0 ? delay : GOURD_TURN_SEC,
returning: false,
});
}
/**
* 主目标格世界坐标(与迷雾落点同一套)。
* @param target 主目标
*/
private _gourdTargetWorld(target: EntityId): Vec3 | null {
if (!(target > 0)) return null;
return SkillPresentationEnv.getTargetGridWorldCenter(target)
?? SkillPresentationEnv.getTargetWorldPos(target);
}
/**
* 葫芦根朝向主目标格的本地角。
* @param root 葫芦
* @param target 主目标
*/
private _gourdAimDeg(root: Node, target: EntityId): number | null {
const to = this._gourdTargetWorld(target);
if (!to) return null;
const parent = root.parent;
const parentUt = parent?.getComponent(UITransform);
if (!parentUt) return null;
parentUt.convertToNodeSpaceAR(to, this._gourdLocal);
const dx = this._gourdLocal.x - root.position.x;
const dy = this._gourdLocal.y - root.position.y;
if (!(dx * dx + dy * dy > 1e-6)) return null;
return Math.atan2(dy, dx) * 180 / Math.PI;
}
/** 推进葫芦:播完回正后回池 */
private _tickGourds(dt: number): void {
for (let i = this._gourds.length - 1; i >= 0; i--) {
const fx = this._gourds[i];
if (!fx.root?.isValid) {
this._gourds.splice(i, 1);
continue;
}
fx.wait -= dt;
if (fx.wait > 0) continue;
if (fx.returning) continue;
fx.returning = true;
SkillTransformDriver.cancelNode(fx.root);
SkillTransformDriver.animate({
node: fx.root,
duration: GOURD_TURN_SEC,
fromAngle: fx.root.angle,
toAngle: fx.restDeg,
onComplete: this._onGourdRestored,
});
}
}
/** 回正结束:回池葫芦 */
private readonly _onGourdRestored = (node?: Node): void => {
if (!node?.isValid) return;
for (let i = this._gourds.length - 1; i >= 0; i--) {
if (this._gourds[i].root !== node) continue;
this._recycleGourdNode(node, this._gourds[i].restDeg);
this._gourds.splice(i, 1);
return;
}
this._recycleGourdNode(node, GOURD_REST_DEG);
};
/**
* 立刻回池一只葫芦。
* @param entity 施法者
*/
private _recycleGourdOf(entity: EntityId): void {
for (let i = this._gourds.length - 1; i >= 0; i--) {
if (this._gourds[i].entity !== entity) continue;
const fx = this._gourds[i];
this._gourds.splice(i, 1);
this._recycleGourdNode(fx.root, fx.restDeg);
}
}
/** 回池全部葫芦 */
private _recycleAllGourds(): void {
for (let i = 0; i < this._gourds.length; i++) {
this._recycleGourdNode(this._gourds[i].root, this._gourds[i].restDeg);
}
this._gourds.length = 0;
}
/**
* 停转向、复位后回池。
* @param root 葫芦
* @param restDeg 默认角
*/
private _recycleGourdNode(root: Node, restDeg: number): void {
if (!root?.isValid) return;
SkillTransformDriver.cancelNode(root);
const sk = root.getComponentInChildren(sp.Skeleton);
BattleSpinePlayback.clearPendingComplete(sk);
BattleSpinePlayback.resetTracks(sk);
root.angle = restDeg;
root.setPosition(GOURD_REST_X, GOURD_REST_Y, 0);
root.setScale(1, 1, 1);
gg.res.putNode(root);
}
/**
* Skill.skill 类型 9:与葫芦同时挂迷雾并渐显。
* atk_type=15:盖在主目标格上并按 atk_para 半径缩放;否则对方半屏。
* @param ctx 本次施法
* @param fadeIn 渐显秒
*/
private _spawnFogIfNeeded(ctx: SkillPresentationContext, fadeIn: number): void {
const raw = String(ctx.params.extraSkill ?? ctx.params.skillRaw ?? '');
const parts = raw.split(',');
const head = Math.floor(Number(parts[0]) || 0);
if (head !== EXTRA_FOG) return;
const duration = Number(parts[1]);
if (!(duration > 0)) {
console.log('[MonsterSkillHost] skill 类型9缺少持续时间', ctx.abilityId);
return;
}
const battle = this._battle;
if (!battle) {
console.log('[MonsterSkillHost] 未绑定,无法挂迷雾', ctx.abilityId);
return;
}
const team = battle.session.world.getComponent(ctx.caster, TeamComp);
if (!team) {
console.log('[MonsterSkillHost] 施法者无 TeamComp', ctx.caster);
return;
}
const flyLayer = SkillPresentationEnv.flyLayer;
if (!flyLayer?.isValid) {
console.log('[MonsterSkillHost] flyLayer 缺失', FOG_PREFAB);
return;
}
let node: Node | null = null;
try {
node = gg.res.getNode(GPath.HeroSkillPrefab(), FOG_PREFAB, GBundle.BattleHero);
} catch (err) {
console.log('[MonsterSkillHost] getNode 失败', FOG_PREFAB, String(err));
return;
}
if (!node?.isValid) {
console.log('[MonsterSkillHost] 预制体未加载', FOG_PREFAB);
return;
}
flyLayer.active = true;
node.active = true;
node.layer = flyLayer.layer;
node.parent = flyLayer;
const atkHead = Math.floor(Number(ctx.params.atkTypeHead ?? ctx.params.atkType) || 0);
const localCover = atkHead === 15;
const placed = localCover
? this._placeFogOnPrimary(node, ctx)
: this._placeFogHalfScreen(node, flyLayer, this._fogOnUpperHalf(team));
if (!placed) {
node.setScale(1, 1, 1);
gg.res.putNode(node);
return;
}
node.setSiblingIndex(flyLayer.children.length - 1);
const opacity = node.getComponent(UIOpacity) ?? node.addComponent(UIOpacity);
const appear = fadeIn > 0 ? fadeIn : 0;
opacity.opacity = appear > 0 ? 0 : 255;
const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton);
if (!sk?.isValid) {
console.log('[MonsterSkillHost] 迷雾缺少 Skeleton', FOG_PREFAB);
node.setScale(1, 1, 1);
gg.res.putNode(node);
return;
}
if (BattleSpinePlayback.getAnimDuration(sk, FOG_ANIM) == null) {
console.log('[MonsterSkillHost] 迷雾无动画', FOG_ANIM, FOG_PREFAB);
node.setScale(1, 1, 1);
gg.res.putNode(node);
return;
}
BattleSpinePlayback.play(sk, FOG_ANIM, { loop: true });
if (localCover) this._hideFogLocalSlots(sk);
this._fogs.push({
node,
opacity,
appear,
appearTotal: appear,
hold: duration,
fade: -1,
localCover,
});
}
/**
* atk_type=15:迷雾挂主目标;节点缩放到 FOG_LOCAL_SCALE(UITransform 改 ContentSize 不影响 Spine 视觉)。
* @param node 迷雾
* @param ctx 施法上下文
*/
private _placeFogOnPrimary(node: Node, ctx: SkillPresentationContext): boolean {
const primary = ctx.targets[0] ?? 0;
if (!(primary > 0)) {
console.log('[MonsterSkillHost] atk_type=15 无主目标,无法挂迷雾', ctx.abilityId);
return false;
}
const world = this._gourdTargetWorld(primary);
if (!world) {
console.log('[MonsterSkillHost] 主目标无格子坐标', primary, ctx.abilityId);
return false;
}
node.setScale(FOG_LOCAL_SCALE_X, FOG_LOCAL_SCALE_Y, 1);
node.setWorldPosition(world.x, world.y, world.z);
return true;
}
/**
* 藏全屏紫雾与怪身槽;动画可能每帧写回,需配合 tick 重复调用。
* @param sk 迷雾 Skeleton
*/
private _hideFogLocalSlots(sk: sp.Skeleton): void {
if (!sk?.isValid || typeof sk.findSlot !== 'function') return;
for (let i = 0; i < FOG_LOCAL_HIDE_SLOTS.length; i++) {
const name = FOG_LOCAL_HIDE_SLOTS[i];
const slot = sk.findSlot(name) as { color?: { a: number }; attachment?: unknown; setAttachment?: (a: null) => void } | null;
if (!slot) {
console.log('[MonsterSkillHost] 迷雾局部缺槽', name);
continue;
}
if (slot.color) slot.color.a = 0;
if (typeof slot.setAttachment === 'function') slot.setAttachment(null);
else slot.attachment = null;
}
}
/**
* 迷雾盖对方半屏:敌方打本机=下半屏,本机侧打对方=上半屏。
* @param team 施法者阵营
*/
private _fogOnUpperHalf(team: TeamComp): boolean {
const localId = this._battle?.player?.playerId ?? 0;
const ownerId = Math.floor(Number(team.ownerId) || 0);
const targetPlayerId = BattleTeamId.isEnemyTeam(team.team) ? ownerId : 1 - ownerId;
return targetPlayerId !== localId;
}
/**
* 挂到 flyLayer:x=0,y 为对阵半屏中心(不锁格子)。
* @param node 迷雾
* @param flyLayer ui_fly
* @param upper 是否上半屏(敌方)
*/
private _placeFogHalfScreen(node: Node, flyLayer: Node, upper: boolean): boolean {
const board = flyLayer.parent;
const boardUt = board?.getComponent(UITransform);
const flyUt = flyLayer.getComponent(UITransform);
if (!boardUt || !flyUt) {
console.log('[MonsterSkillHost] 半屏定位缺少 UITransform');
return false;
}
const h = boardUt.height;
if (!(h > 0)) {
console.log('[MonsterSkillHost] 战斗界面高度无效', h);
return false;
}
node.setScale(1, 1, 1);
this._fogLocal.set(0, upper ? h * 0.25 : -h * 0.25, 0);
boardUt.convertToWorldSpaceAR(this._fogLocal, this._fogWorld);
const local = flyUt.convertToNodeSpaceAR(this._fogWorld);
node.setPosition(0, local.y, 0);
return true;
}
/**
* 推进迷雾保持与渐隐。
* @param dt 战斗缩放秒
*/
private _tickFogs(dt: number): void {
if (this._fogs.length <= 0) return;
for (let i = this._fogs.length - 1; i >= 0; i--) {
const fog = this._fogs[i];
if (!fog.node.isValid) {
this._fogs.splice(i, 1);
continue;
}
if (fog.localCover) {
const sk = fog.node.getComponent(sp.Skeleton) ?? fog.node.getComponentInChildren(sp.Skeleton);
if (sk?.isValid) this._hideFogLocalSlots(sk);
}
if (fog.appear > 0 && fog.appearTotal > 0) {
fog.appear -= dt;
const t = 1 - Math.max(fog.appear, 0) / fog.appearTotal;
fog.opacity.opacity = Math.round(255 * t);
if (fog.appear > 0) continue;
fog.appear = 0;
fog.opacity.opacity = 255;
}
if (fog.hold > 0) {
fog.hold -= dt;
if (fog.hold > 0) continue;
fog.hold = 0;
fog.fade = FOG_FADE_SEC;
}
if (fog.fade < 0) continue;
fog.fade -= dt;
if (fog.fade > 0) {
fog.opacity.opacity = Math.round(255 * (fog.fade / FOG_FADE_SEC));
continue;
}
this._fogs.splice(i, 1);
this._recycleFog(fog);
}
}
/**
* 回池单团迷雾。
* @param fog 实例
*/
private _recycleFog(fog: FogFx): void {
const node = fog.node;
if (!node?.isValid) return;
const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton);
BattleSpinePlayback.clearPendingComplete(sk);
BattleSpinePlayback.resetTracks(sk);
if (sk?.isValid && typeof sk.setSlotsToSetupPose === 'function') {
sk.setSlotsToSetupPose();
}
fog.opacity.opacity = 255;
node.setScale(1, 1, 1);
gg.res.putNode(node);
const flyLayer = SkillPresentationEnv.flyLayer;
if (flyLayer?.isValid && flyLayer.children.length === 0) flyLayer.active = false;
}
/** 回收全部迷雾 */
private _recycleAllFogs(): void {
for (let i = 0; i < this._fogs.length; i++) {
this._recycleFog(this._fogs[i]);
}
this._fogs.length = 0;
}
/**
* atk_buff1 类型 9:施法起播时出千里眼技能,瞄向目标格并封格。
* @param ctx 本次施法
* @param wait 技能动画秒
*/
private _beginEyeIfNeeded(ctx: SkillPresentationContext, wait: number): void {
const raw = String(ctx.params.buff1 ?? '');
const head = Math.floor(Number(raw.split(',')[0]) || 0);
if (head !== BUFF_SEAL) return;
const count = Math.floor(Number(String(ctx.params.atkPara ?? '').split(',')[0]) || 0);
if (!(count > 0)) {
console.log('[MonsterSkillHost] atk_buff1 类型9缺少封格数量', ctx.abilityId, ctx.params.atkPara);
return;
}
const battle = this._battle;
if (!battle) {
console.log('[MonsterSkillHost] 未绑定,无法封格', ctx.abilityId);
return;
}
const grids = pickSealablePutTiles(battle, ctx.caster, count);
if (grids.length <= 0) return;
const hold = wait > 0 ? wait : EYE_HOLD_SEC;
for (let i = 0; i < grids.length; i++) {
const node = this._spawnEyeFx(ctx.caster, grids[i]);
if (node) this._eyes.push({ caster: ctx.caster, node, hold });
}
sealPlaceableTiles(battle, ctx.caster, grids);
}
/**
* 在 Boss 处出池千里眼技能并瞄向目标格。
* @param caster 施法者
* @param gridIndex 目标格
*/
private _spawnEyeFx(caster: EntityId, gridIndex: number): Node | null {
const flyLayer = SkillPresentationEnv.flyLayer;
if (!flyLayer?.isValid) {
console.log('[MonsterSkillHost] flyLayer 缺失', EYE_PREFAB);
return null;
}
const boss = SkillPresentationEnv.getMonsterNode(caster);
if (!boss?.isValid) {
console.log('[MonsterSkillHost] 千里眼无 Boss 节点', caster);
return null;
}
let node: Node | null = null;
try {
node = gg.res.getNode(GPath.HeroSkillPrefab(), EYE_PREFAB, GBundle.BattleHero);
} catch (err) {
console.log('[MonsterSkillHost] getNode 失败', EYE_PREFAB, String(err));
return null;
}
if (!node?.isValid) {
console.log('[MonsterSkillHost] 预制体未加载', EYE_PREFAB);
return null;
}
const flyUt = flyLayer.getComponent(UITransform);
if (!flyUt) {
console.log('[MonsterSkillHost] 千里眼 flyLayer 无 UITransform');
gg.res.putNode(node);
return null;
}
flyLayer.active = true;
node.active = true;
node.layer = flyLayer.layer;
node.setScale(1, 1, 1);
node.angle = 0;
this._resetEyeNode(node);
node.parent = flyLayer;
node.setSiblingIndex(flyLayer.children.length - 1);
node.setPosition(flyUt.convertToNodeSpaceAR(boss.worldPosition));
if (!this._aimEyeFx(node, gridIndex)) {
this._resetEyeNode(node);
gg.res.putNode(node);
return null;
}
this._playEyeDefaultLoops(node);
return node;
}
/**
* 光束朝向目标并按距离拉 X 缩放;落点特效放到目标格。
* @param root 千里眼技能根
* @param gridIndex 目标格
*/
private _aimEyeFx(root: Node, gridIndex: number): boolean {
const map = this._battle?.Map;
const mg = map?.getMapGridByIndex(gridIndex);
if (!mg?.node?.isValid) {
console.log('[MonsterSkillHost] 千里眼目标格缺失', gridIndex);
return false;
}
const rootUt = root.getComponent(UITransform);
if (!rootUt) {
console.log('[MonsterSkillHost] 千里眼根节点无 UITransform');
return false;
}
const beam = root.getChildByName(EYE_BEAM_NODE);
const hit = root.getChildByName(EYE_HIT_NODE);
if (!beam?.isValid || !hit?.isValid) {
console.log('[MonsterSkillHost] 千里眼缺少子节点', EYE_BEAM_NODE, EYE_HIT_NODE);
return false;
}
const local = rootUt.convertToNodeSpaceAR(mg.node.worldPosition, this._eyeLocal);
hit.setPosition(local);
const dx = local.x - beam.position.x;
const dy = local.y - beam.position.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (!(dist > 1e-4)) {
console.log('[MonsterSkillHost] 千里眼光束距离为零', gridIndex);
return false;
}
const beamUt = beam.getComponent(UITransform);
const base = beamUt?.width ?? 0;
if (!(base > 1e-4)) {
console.log('[MonsterSkillHost] 千里眼光束宽度无效');
return false;
}
beam.angle = Math.atan2(dy, dx) * 180 / Math.PI;
beam.setScale(dist / base, 1, 1);
return true;
}
/**
* 光束与落点播预制体默认动画(循环)。
* @param root 千里眼技能根
*/
private _playEyeDefaultLoops(root: Node): void {
const hit = root.getChildByName(EYE_HIT_NODE);
const beam = root.getChildByName(EYE_BEAM_NODE);
if (hit?.isValid) this._playNodeDefaultLoop(hit, EYE_HIT_NODE);
if (beam?.isValid) this._playNodeDefaultLoop(beam, EYE_BEAM_NODE);
}
/**
* 节点默认动画循环。
* @param node 带 Skeleton 的节点
* @param label 日志名
*/
private _playNodeDefaultLoop(node: Node, label: string): void {
const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton);
if (!sk?.isValid) {
console.log('[MonsterSkillHost] 千里眼缺少 Skeleton', label);
return;
}
const name = String((sk as sp.Skeleton & { defaultAnimation?: string }).defaultAnimation ?? '').trim();
if (!name) {
console.log('[MonsterSkillHost] 千里眼无默认动画', label);
return;
}
if (!BattleSpinePlayback.play(sk, name, { loop: true })) {
console.log('[MonsterSkillHost] 千里眼默认动画起播失败', label, name);
}
}
/**
* 推进千里眼特效保持。
* @param dt 战斗缩放秒
*/
private _tickEyes(dt: number): void {
if (this._eyes.length <= 0) return;
for (let i = this._eyes.length - 1; i >= 0; i--) {
const fx = this._eyes[i];
if (!fx.node.isValid) {
this._eyes.splice(i, 1);
continue;
}
fx.hold -= dt;
if (fx.hold > 0) continue;
this._eyes.splice(i, 1);
this._recycleEye(fx);
}
}
/**
* 回收某施法者的千里眼特效。
* @param caster 施法者
*/
private _recycleEyesOf(caster: EntityId): void {
for (let i = this._eyes.length - 1; i >= 0; i--) {
if (this._eyes[i].caster !== caster) continue;
const fx = this._eyes[i];
this._eyes.splice(i, 1);
this._recycleEye(fx);
}
}
/** 回收全部千里眼特效 */
private _recycleAllEyes(): void {
for (let i = 0; i < this._eyes.length; i++) {
this._recycleEye(this._eyes[i]);
}
this._eyes.length = 0;
}
/**
* 回池单条千里眼。
* @param fx 实例
*/
private _recycleEye(fx: EyeFx): void {
const node = fx.node;
if (!node?.isValid) return;
this._resetEyeNode(node);
gg.res.putNode(node);
const flyLayer = SkillPresentationEnv.flyLayer;
if (flyLayer?.isValid && flyLayer.children.length === 0) flyLayer.active = false;
}
/**
* 回池前复位缩放朝向与 Spine。
* @param node 根节点
*/
private _resetEyeNode(node: Node): void {
const beam = node.getChildByName(EYE_BEAM_NODE);
if (beam?.isValid) {
beam.angle = 0;
beam.setScale(1, 1, 1);
}
const sks = node.getComponentsInChildren(sp.Skeleton);
for (let i = 0; i < sks.length; i++) {
BattleSpinePlayback.clearPendingComplete(sks[i]);
BattleSpinePlayback.resetTracks(sks[i]);
}
node.angle = 0;
node.setScale(1, 1, 1);
}
/**
* target_stage=2:技能动画结束后给射程内同侧友军怪上 atk_buff。
* @param ctx 本次施法
*/
private _buffAlliesIfNeeded(ctx: SkillPresentationContext): void {
if (Math.floor(Number(ctx.params.skillType) || 0) === EXTRA_DEVOUR) return;
if (Math.floor(Number(ctx.params.stageStrategy) || 0) !== 2) return;
const buff1 = String(ctx.params.buff1 ?? '').trim();
const buff2 = String(ctx.params.buff2 ?? '').trim();
if (!buff1 && !buff2) return;
const battle = this._battle;
if (!battle) {
console.log('[MonsterSkillHost] 未绑定,无法给友军上 buff', ctx.abilityId);
return;
}
const rangePx = Number(ctx.params.stageRangePx);
if (!(rangePx > 0)) {
console.log('[MonsterSkillHost] 友军 buff 缺少射程', ctx.abilityId, ctx.params.stageRangePx);
return;
}
const ids = this._collectSameSideMonsters(ctx.caster, rangePx);
if (ids.length <= 0) {
console.log('[MonsterSkillHost] 射程内无同侧友军(含自身)', ctx.abilityId, rangePx);
return;
}
const abilityCtx = createAbilityContext(battle.session, ctx.caster, ctx.abilityId, ids);
const speedParts = buff1.split(',');
const speedHead = Math.floor(Number(speedParts[0]) || 0);
const speedDur = Number(speedParts[3]);
for (let i = 0; i < ids.length; i++) {
const id = ids[i];
SkillAtkBuffExecutor.applyPair(battle.session, abilityCtx, id, buff1, buff2);
if (speedHead === BUFF_SPEED && speedDur > 0) {
this._playAllySpeedFx(id, speedDur);
}
}
}
/**
* 扫描射程内同侧友军怪(含自身,太白回血/加速对自己生效)。
* @param caster 施法者
* @param rangePx 射程像素
*/
private _collectSameSideMonsters(caster: EntityId, rangePx: number): EntityId[] {
this._allyScanIds.length = 0;
this._allyScanCaster = caster;
this._allyScanRangePx = rangePx;
this._battle?.session.world.forEachAlive(this._onAllyScan);
if (this._isAllyBuffSelf(caster)) this._allyScanIds.push(caster);
return this._allyScanIds;
}
/**
* 施法者是否可作为友军 buff 目标。
* @param caster 施法者
*/
private _isAllyBuffSelf(caster: EntityId): boolean {
const battle = this._battle;
if (!battle) return false;
const world = battle.session.world;
if (!world.isAlive(caster)) return false;
const hp = world.getComponent(caster, HealthComp);
if (!hp || !(hp.hp > 0)) return false;
if (world.getComponent(caster, MovementComp)?.spawnProtected) return false;
const meta = world.getComponent(caster, UnitMetaComp);
if (!meta?.unitDefId) return false;
const parsed = parseMonsterUnitDefId(meta.unitDefId);
const monsterId = parsed?.monsterId ?? parseLegacyMonsterId(meta.unitDefId);
return monsterId > 0;
}
/** 友军扫描回调 */
private readonly _onAllyScan = (id: EntityId): void => {
if (!this._isSameSideMonster(this._allyScanCaster, id, this._allyScanRangePx)) return;
this._allyScanIds.push(id);
};
/**
* 在友军身上挂加速特效,持续结束回收。
* @param entity 目标
* @param duration 秒
*/
private _playAllySpeedFx(entity: EntityId, duration: number): void {
const view = this._monsterView(entity);
if (!view?.showAccelerateFx) {
console.log('[MonsterSkillHost] 友军加速特效不可用', entity);
return;
}
view.showAccelerateFx();
for (let i = 0; i < this._allySpeeds.length; i++) {
if (this._allySpeeds[i].entity === entity) {
this._allySpeeds[i].wait = duration;
return;
}
}
this._allySpeeds.push({ entity, wait: duration });
}
/**
* 推进友军加速特效。
* @param dt 战斗缩放秒
*/
private _tickAllySpeeds(dt: number): void {
if (this._allySpeeds.length <= 0) return;
const world = this._battle?.session.world;
for (let i = this._allySpeeds.length - 1; i >= 0; i--) {
const fx = this._allySpeeds[i];
fx.wait -= dt;
const dead = !world || !world.isAlive(fx.entity);
if (fx.wait > 0 && !dead) continue;
this._allySpeeds.splice(i, 1);
this._clearAllySpeedFx(fx.entity);
}
}
/**
* 回收单个加速特效。
* @param entity 目标
*/
private _clearAllySpeedFx(entity: EntityId): void {
this._monsterView(entity)?.clearAccelerateFx?.();
}
/** 回收全部友军加速特效 */
private _clearAllAllySpeeds(): void {
for (let i = 0; i < this._allySpeeds.length; i++) {
this._clearAllySpeedFx(this._allySpeeds[i].entity);
}
this._allySpeeds.length = 0;
}
/**
* 怪物视图。
* @param entity 实体
*/
private _monsterView(entity: EntityId): IBattleMonsterView | null {
const node = SkillPresentationEnv.getMonsterNode(entity);
if (!node?.isValid) return null;
return node.getComponent(BATTLE_MONSTER_CCCLASS) as IBattleMonsterView | null;
}
/**
* atk_buff1 类型 12:技能动画结束后在锁定基础字格播损毁文字。
* @param ctx 本次施法
*/
private _spawnDestroyIfNeeded(ctx: SkillPresentationContext): void {
const raw = String(ctx.params.buff1 ?? '');
const parts = raw.split(',');
const head = Math.floor(Number(parts[0]) || 0);
if (head !== BUFF_DESTROY) return;
if (parts.length < 2 || !Number.isFinite(Number(parts[1]))) {
console.log('[MonsterSkillHost] atk_buff1 类型12缺少概率', ctx.abilityId);
return;
}
const wan = Number(parts[1]);
const battle = this._battle;
if (!battle) {
console.log('[MonsterSkillHost] 未绑定,无法损毁', ctx.abilityId);
return;
}
if (wan < 10000 && battle.session.rng.next() * 10000 >= wan) return;
const target = ctx.targets[0];
if (target == null) {
console.log('[MonsterSkillHost] 类型12无锁定目标', ctx.abilityId);
return;
}
if (getTowerRuinHost().isEntityRuined(target)) return;
const team = battle.session.world.getComponent(target, TeamComp);
if (!team) {
console.log('[MonsterSkillHost] 损毁目标无 TeamComp', target);
return;
}
const playerId = Math.floor(Number(team.ownerId) || 0);
const player = battle.getPlayer(playerId);
const map = battle.Map;
if (!player || !map) {
console.log('[MonsterSkillHost] 损毁目标玩家或地图缺失', target, playerId);
return;
}
const gridIndex = findGridIndexForEntity(target, player, battle.session, map);
if (gridIndex == null) {
console.log('[MonsterSkillHost] 损毁目标无格子', target);
return;
}
if (!this._playDestroyFx(gridIndex, target)) {
this._applyDestroy(target);
}
}
/**
* 在格子中心出池损毁文字并播默认动画。
* @param gridIndex 目标格
* @param target 锁定实体
* @returns 是否已挂起等待动画
*/
private _playDestroyFx(gridIndex: number, target: EntityId): boolean {
const flyLayer = SkillPresentationEnv.flyLayer;
if (!flyLayer?.isValid) {
console.log('[MonsterSkillHost] flyLayer 缺失', DESTROY_PREFAB);
return false;
}
let node: Node | null = null;
try {
node = gg.res.getNode(GPath.HeroSkillPrefab(), DESTROY_PREFAB, GBundle.BattleHero);
} catch (err) {
console.log('[MonsterSkillHost] getNode 失败', DESTROY_PREFAB, String(err));
return false;
}
if (!node?.isValid) {
console.log('[MonsterSkillHost] 预制体未加载', DESTROY_PREFAB);
return false;
}
const world = SkillPresentationEnv.getGridWorldCenter(gridIndex);
const flyUt = flyLayer.getComponent(UITransform);
if (!world || !flyUt) {
console.log('[MonsterSkillHost] 损毁文字定位失败', gridIndex);
gg.res.putNode(node);
return false;
}
flyLayer.active = true;
node.active = true;
node.layer = flyLayer.layer;
node.parent = flyLayer;
node.setSiblingIndex(flyLayer.children.length - 1);
const local = flyUt.convertToNodeSpaceAR(world);
node.setPosition(local);
const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton);
if (!sk?.isValid) {
console.log('[MonsterSkillHost] 损毁文字缺少 Skeleton', DESTROY_PREFAB);
gg.res.putNode(node);
return false;
}
const animName = String((sk as sp.Skeleton & { defaultAnimation?: string }).defaultAnimation ?? '').trim();
if (!animName) {
console.log('[MonsterSkillHost] 损毁文字无默认动画', DESTROY_PREFAB);
gg.res.putNode(node);
return false;
}
const delay = BattleSpinePlayback.resolveLogicalCompleteDelay(sk, animName, 1);
if (delay == null) {
console.log('[MonsterSkillHost] 损毁文字无动画', animName, DESTROY_PREFAB);
gg.res.putNode(node);
return false;
}
if (!BattleSpinePlayback.play(sk, animName, { loop: false })) {
gg.res.putNode(node);
return false;
}
this._destroys.push({ node, target, wait: Math.max(0, delay) });
return true;
}
/**
* 推进损毁文字播放,结束后使目标失效。
* @param dt 战斗缩放秒
*/
private _tickDestroys(dt: number): void {
if (this._destroys.length <= 0) return;
for (let i = this._destroys.length - 1; i >= 0; i--) {
const fx = this._destroys[i];
fx.wait -= dt;
if (fx.wait > 0 && fx.node.isValid) continue;
this._destroys.splice(i, 1);
this._finishDestroy(fx);
}
}
/**
* 回收特效并使目标失效。
* @param fx 实例
*/
private _finishDestroy(fx: DestroyFx): void {
this._recycleDestroyNode(fx.node);
this._applyDestroy(fx.target);
}
/**
* 基础字失效:禁攻,合成升级前保持损毁。
* @param target 锁定实体
*/
private _applyDestroy(target: EntityId): void {
const battle = this._battle;
if (!battle) return;
getTowerRuinHost().apply(battle, target);
}
/**
* 回池损毁文字。
* @param node 特效
*/
private _recycleDestroyNode(node: Node): void {
if (!node?.isValid) return;
const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton);
BattleSpinePlayback.clearPendingComplete(sk);
BattleSpinePlayback.resetTracks(sk);
gg.res.putNode(node);
const flyLayer = SkillPresentationEnv.flyLayer;
if (flyLayer?.isValid && flyLayer.children.length === 0) flyLayer.active = false;
}
/** 回收全部损毁文字(不解算单位) */
private _recycleAllDestroys(): void {
for (let i = 0; i < this._destroys.length; i++) {
this._recycleDestroyNode(this._destroys[i].node);
}
this._destroys.length = 0;
}
/**
* atk_buff1 类型 6:技能动画结束后降攻速;skill 类型 9(奔波儿灞迷雾)不挂降雨特效。
* @param ctx 本次施法
*/
private _spawnRainIfNeeded(ctx: SkillPresentationContext): void {
const raw = String(ctx.params.buff1 ?? '');
const parts = raw.split(',');
const head = Math.floor(Number(parts[0]) || 0);
if (head !== BUFF_RAIN) return;
if (parts.length < 2 || !Number.isFinite(Number(parts[1]))) {
console.log('[MonsterSkillHost] atk_buff1 类型6缺少概率', ctx.abilityId);
return;
}
const duration = Number(parts[3]);
if (!(duration > 0)) {
console.log('[MonsterSkillHost] atk_buff1 类型6缺少持续时间', ctx.abilityId);
}
const battle = this._battle;
if (!battle) {
console.log('[MonsterSkillHost] 未绑定,无法降雨', ctx.abilityId);
return;
}
const targets = ctx.targets;
if (targets.length <= 0) {
console.log('[MonsterSkillHost] 类型6无锁定目标', ctx.abilityId);
return;
}
const extraHead = Math.floor(Number(ctx.params.skillType ?? 0) || 0);
const playRain = extraHead !== EXTRA_FOG;
const abilityCtx = createAbilityContext(battle.session, ctx.caster, ctx.abilityId, targets);
const buff2 = String(ctx.params.buff2 ?? '');
for (let i = 0; i < targets.length; i++) {
const target = targets[i];
SkillAtkBuffExecutor.applyPair(battle.session, abilityCtx, target, raw, buff2);
if (!playRain || !(duration > 0)) continue;
if (!this._playRainFx(target, duration)) {
console.log('[MonsterSkillHost] 降雨特效失败仍已上 buff', target, ctx.abilityId);
}
}
}
/**
* 在文字格上方出池降雨并循环播默认动画。
* @param target 锁定实体
* @param duration 配表持续秒
* @returns 是否已挂起
*/
private _playRainFx(target: EntityId, duration: number): boolean {
const battle = this._battle;
if (!battle) return false;
const team = battle.session.world.getComponent(target, TeamComp);
if (!team) {
console.log('[MonsterSkillHost] 降雨目标无 TeamComp', target);
return false;
}
const playerId = Math.floor(Number(team.ownerId) || 0);
const player = battle.getPlayer(playerId);
const map = battle.Map;
if (!player || !map) {
console.log('[MonsterSkillHost] 降雨目标玩家或地图缺失', target, playerId);
return false;
}
const gridIndex = findGridIndexForEntity(target, player, battle.session, map);
if (gridIndex == null) {
console.log('[MonsterSkillHost] 降雨目标无格子', target);
return false;
}
const flyLayer = SkillPresentationEnv.flyLayer;
if (!flyLayer?.isValid) {
console.log('[MonsterSkillHost] flyLayer 缺失', RAIN_PREFAB);
return false;
}
let node: Node | null = null;
try {
node = gg.res.getNode(GPath.HeroSkillPrefab(), RAIN_PREFAB, GBundle.BattleHero);
} catch (err) {
console.log('[MonsterSkillHost] getNode 失败', RAIN_PREFAB, String(err));
return false;
}
if (!node?.isValid) {
console.log('[MonsterSkillHost] 预制体未加载', RAIN_PREFAB);
return false;
}
const world = SkillPresentationEnv.getGridWorldCenter(gridIndex);
const flyUt = flyLayer.getComponent(UITransform);
if (!world || !flyUt) {
console.log('[MonsterSkillHost] 降雨定位失败', gridIndex);
gg.res.putNode(node);
return false;
}
flyLayer.active = true;
node.active = true;
node.layer = flyLayer.layer;
node.parent = flyLayer;
node.setSiblingIndex(flyLayer.children.length - 1);
const local = flyUt.convertToNodeSpaceAR(world);
local.y += RAIN_ABOVE_PX;
node.setPosition(local);
const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton);
if (!sk?.isValid) {
console.log('[MonsterSkillHost] 降雨缺少 Skeleton', RAIN_PREFAB);
gg.res.putNode(node);
return false;
}
const animName = String((sk as sp.Skeleton & { defaultAnimation?: string }).defaultAnimation ?? '').trim();
if (!animName) {
console.log('[MonsterSkillHost] 降雨无默认动画', RAIN_PREFAB);
gg.res.putNode(node);
return false;
}
if (!BattleSpinePlayback.play(sk, animName, { loop: true })) {
gg.res.putNode(node);
return false;
}
this._rains.push({ node, hold: duration });
return true;
}
/**
* 推进降雨保持,结束后回池。
* @param dt 战斗缩放秒
*/
private _tickRains(dt: number): void {
if (this._rains.length <= 0) return;
for (let i = this._rains.length - 1; i >= 0; i--) {
const fx = this._rains[i];
if (!fx.node.isValid) {
this._rains.splice(i, 1);
continue;
}
fx.hold -= dt;
if (fx.hold > 0) continue;
this._rains.splice(i, 1);
this._recycleRain(fx);
}
}
/**
* 回池单团降雨。
* @param fx 实例
*/
private _recycleRain(fx: RainFx): void {
const node = fx.node;
if (!node?.isValid) return;
const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton);
BattleSpinePlayback.clearPendingComplete(sk);
BattleSpinePlayback.resetTracks(sk);
gg.res.putNode(node);
const flyLayer = SkillPresentationEnv.flyLayer;
if (flyLayer?.isValid && flyLayer.children.length === 0) flyLayer.active = false;
}
/** 回收全部降雨 */
private _recycleAllRains(): void {
for (let i = 0; i < this._rains.length; i++) {
this._recycleRain(this._rains[i]);
}
this._rains.length = 0;
}
/**
* skill 类型 5:施法同时锁定本方周围小兵并吸入。
* @param ctx 本次施法
* @param duration 技能动画秒
*/
private _beginDevour(ctx: SkillPresentationContext, duration: number): void {
const spec = this._readDevourSpec(ctx);
if (!spec) return;
const battle = this._battle;
if (!battle) {
console.log('[MonsterSkillHost] 未绑定,无法吞噬', ctx.abilityId);
return;
}
this._abortDevour(ctx.caster);
const ids = this._pickDevourIds(ctx, spec.maxN);
const victims: DevourVictim[] = [];
for (let i = 0; i < ids.length; i++) {
this._lockDevourVictim(ids[i], victims);
}
if (victims.length <= 0) {
console.log('[MonsterSkillHost] 类型5无周围小兵', ctx.abilityId, ctx.targets.length);
return;
}
this._devours.push({
caster: ctx.caster,
victims,
elapsed: 0,
duration: Math.max(0, duration),
bonusWan: spec.bonusWan,
});
}
/**
* 读 skill 类型 5 参数。
* @param ctx 本次施法
*/
private _readDevourSpec(ctx: SkillPresentationContext): { maxN: number; bonusWan: number } | null {
const parts = this._devourParts(ctx);
if ((parts[0] ?? 0) !== EXTRA_DEVOUR) return null;
const maxN = Math.floor(Number(parts[1]) || 0);
if (!(maxN > 0)) {
console.log('[MonsterSkillHost] skill 类型5缺少最大数量', ctx.abilityId, parts);
return null;
}
const bonusWan = Number(parts[2]);
if (!Number.isFinite(bonusWan)) {
console.log('[MonsterSkillHost] skill 类型5缺少每个额外加成', ctx.abilityId, parts);
return { maxN, bonusWan: 0 };
}
return { maxN, bonusWan };
}
/**
* extraSkill / skillParts / skillRaw 解析类型 5。
* @param ctx 本次施法
*/
private _devourParts(ctx: SkillPresentationContext): number[] {
const extra = String(ctx.params.extraSkill ?? '').trim();
if (extra) {
const fromExtra = extra.split(',').map(Number);
if ((fromExtra[0] ?? 0) === EXTRA_DEVOUR) return fromExtra;
}
const listed = ctx.params.skillParts;
if (Array.isArray(listed) && listed.length > 0) {
const fromList: number[] = [];
for (let i = 0; i < listed.length; i++) {
fromList.push(Number(listed[i]));
}
if ((fromList[0] ?? 0) === EXTRA_DEVOUR) return fromList;
}
const raw = String(ctx.params.skillRaw ?? '').trim();
if (!raw) return [];
return raw.split(',').map(Number);
}
/**
* 周围同侧小兵:先扫场,再补 Ability 已锁目标。
* @param ctx 本次施法
* @param maxN 最大数量
*/
private _pickDevourIds(ctx: SkillPresentationContext, maxN: number): EntityId[] {
const rangePx = Number(ctx.params.stageRangePx);
if (!(rangePx > 0)) {
console.log('[MonsterSkillHost] 类型5缺少射程', ctx.abilityId, ctx.params.stageRangePx);
}
this._devourScanIds.length = 0;
this._devourScanCaster = ctx.caster;
this._devourScanRangePx = rangePx > 0 ? rangePx : 0;
const battle = this._battle;
if (battle && this._devourScanRangePx > 0) {
battle.session.world.forEachAlive(this._onDevourScan);
}
this._sortDevourScan(ctx.caster);
const out: EntityId[] = [];
for (let i = 0; i < this._devourScanIds.length && out.length < maxN; i++) {
out.push(this._devourScanIds[i]);
}
const locked = ctx.targets;
for (let i = 0; i < locked.length && out.length < maxN; i++) {
const id = locked[i];
if (id === ctx.caster) continue;
if (this._idInList(out, id)) continue;
if (!this._isDevourCandidate(ctx.caster, id, 0)) continue;
out.push(id);
}
return out;
}
/** 吞噬扫描:同侧射程内小兵 */
private readonly _onDevourScan = (id: EntityId): void => {
if (!this._isDevourCandidate(this._devourScanCaster, id, this._devourScanRangePx)) return;
this._devourScanIds.push(id);
};
/**
* 是否可吞:存活、同侧、小兵、射程内。
* @param caster 施法者
* @param id 候选
* @param rangePx 射程;≤0 不限距离
*/
private _isDevourCandidate(caster: EntityId, id: EntityId, rangePx: number): boolean {
if (!this._isSameSideMonster(caster, id, rangePx)) return false;
const battle = this._battle;
if (!battle) return false;
const meta = battle.session.world.getComponent(id, UnitMetaComp);
if (!meta?.unitDefId) return false;
const parsed = parseMonsterUnitDefId(meta.unitDefId);
const monsterId = parsed?.monsterId ?? parseLegacyMonsterId(meta.unitDefId);
return MonsterDataManager.isDevourMinion(monsterId);
}
/**
* 存活同侧友军怪且在射程内(不含自身)。
* @param caster 施法者
* @param id 候选
* @param rangePx 射程;≤0 不限距离
*/
private _isSameSideMonster(caster: EntityId, id: EntityId, rangePx: number): boolean {
if (id === caster) return false;
const battle = this._battle;
if (!battle) return false;
const world = battle.session.world;
if (!world.isAlive(id)) return false;
const hp = world.getComponent(id, HealthComp);
if (!hp || !(hp.hp > 0)) return false;
if (world.getComponent(id, MovementComp)?.spawnProtected) return false;
const casterTeam = world.getComponent(caster, TeamComp);
const team = world.getComponent(id, TeamComp);
if (!BattleTeamId.isSameSide(casterTeam, team)) return false;
const meta = world.getComponent(id, UnitMetaComp);
if (!meta?.unitDefId) return false;
const parsed = parseMonsterUnitDefId(meta.unitDefId);
const monsterId = parsed?.monsterId ?? parseLegacyMonsterId(meta.unitDefId);
if (!(monsterId > 0)) return false;
if (!(rangePx > 0)) return true;
return this._devourDistPx(caster, id) <= rangePx;
}
/**
* 施法者与目标格距像素。
* @param caster 施法者
* @param id 目标
*/
private _devourDistPx(caster: EntityId, id: EntityId): number {
const battle = this._battle;
if (!battle) return Number.POSITIVE_INFINITY;
const world = battle.session.world;
const ma = world.getComponent(caster, MovementComp);
const mb = world.getComponent(id, MovementComp);
if (ma && mb) return Math.hypot(ma.worldX - mb.worldX, ma.worldY - mb.worldY);
const a = world.getComponent(caster, TransformComp);
const b = world.getComponent(id, TransformComp);
if (!a || !b) return Number.POSITIVE_INFINITY;
return Math.hypot(a.row - b.row, a.col - b.col) * BattleMapSpec.CELL_SIZE;
}
/**
* 按距离插入排序。
* @param caster 施法者
*/
private _sortDevourScan(caster: EntityId): void {
const list = this._devourScanIds;
for (let i = 1; i < list.length; i++) {
const cur = list[i];
const curD = this._devourDistPx(caster, cur);
let j = i;
while (j > 0 && this._devourDistPx(caster, list[j - 1]) > curD) {
list[j] = list[j - 1];
j -= 1;
}
list[j] = cur;
}
}
/**
* 列表是否已含 id。
* @param list 列表
* @param id 实体
*/
private _idInList(list: EntityId[], id: EntityId): boolean {
for (let i = 0; i < list.length; i++) {
if (list[i] === id) return true;
}
return false;
}
/**
* 锁移动并占用节点;无节点仍吞噬。
* @param id 小兵
* @param out 输出
* @returns 是否锁定成功
*/
private _lockDevourVictim(id: EntityId, out: DevourVictim[]): boolean {
const battle = this._battle;
if (!battle || !battle.session.world.isAlive(id)) return false;
const move = battle.session.world.getComponent(id, MovementComp);
if (move) move.presentLocked = true;
SkillPresentationEnv.holdMonsterVisual(id);
const node = SkillPresentationEnv.getMonsterNode(id);
let fromX = move?.worldX ?? 0;
let fromY = move?.worldY ?? 0;
let scaleX = 1;
let scaleY = 1;
if (node?.isValid) {
fromX = node.worldPosition.x;
fromY = node.worldPosition.y;
scaleX = node.scale.x;
scaleY = node.scale.y;
} else {
const grid = SkillPresentationEnv.getTargetGridWorldCenter(id);
if (grid) {
fromX = grid.x;
fromY = grid.y;
} else {
console.log('[MonsterSkillHost] 吞噬目标无节点,仍会移除', id);
}
}
out.push({ entity: id, fromX, fromY, scaleX, scaleY });
return true;
}
/**
* 吸入靠近施法者。
* @param dt 战斗缩放秒
*/
private _tickDevours(dt: number): void {
if (this._devours.length <= 0) return;
for (let i = 0; i < this._devours.length; i++) {
const run = this._devours[i];
run.elapsed += dt;
const dur = run.duration > 0 ? run.duration : 1;
const u = Math.min(1, run.elapsed / dur);
const t = u * u;
const dest = SkillPresentationEnv.getMonsterNode(run.caster);
const dx = dest?.isValid ? dest.worldPosition.x : 0;
const dy = dest?.isValid ? dest.worldPosition.y : 0;
const hasDest = dest?.isValid === true;
for (let v = 0; v < run.victims.length; v++) {
this._pullDevourVictim(run.victims[v], t, hasDest, dx, dy);
}
}
}
/**
* 单只小兵插值到施法者。
* @param victim 被吸
* @param t 0~1 缓入
* @param hasDest 施法者节点有效
* @param dx 目标 X
* @param dy 目标 Y
*/
private _pullDevourVictim(victim: DevourVictim, t: number, hasDest: boolean, dx: number, dy: number): void {
const node = SkillPresentationEnv.getMonsterNode(victim.entity);
if (!node?.isValid) return;
const shrink = 1 - 0.8 * t;
node.setScale(victim.scaleX * shrink, victim.scaleY * shrink, 1);
if (!hasDest) return;
this._fogWorld.set(
victim.fromX + (dx - victim.fromX) * t,
victim.fromY + (dy - victim.fromY) * t,
node.worldPosition.z,
);
node.setWorldPosition(this._fogWorld);
}
/**
* 吞噬并按只数加血上限。
* @param caster 施法者
*/
private _consumeDevour(caster: EntityId): void {
const idx = this._indexDevour(caster);
if (idx < 0) return;
const run = this._devours[idx];
this._devours.splice(idx, 1);
const battle = this._battle;
if (!battle) {
this._releaseDevourVictims(run, false);
return;
}
let eaten = 0;
for (let i = 0; i < run.victims.length; i++) {
const id = run.victims[i].entity;
SkillPresentationEnv.releaseMonsterVisual(id);
if (!battle.session.world.isAlive(id)) continue;
battle.session.lifecycle.despawn(id);
eaten += 1;
}
if (eaten <= 0) return;
if (run.bonusWan === 0) return;
SkillAtkBuffExecutor.applyMaxHpCoeff(
battle.session.world,
caster,
eaten * run.bonusWan,
battle,
);
}
/**
* 中断吞噬,小兵恢复走路。
* @param caster 施法者
*/
private _abortDevour(caster: EntityId): void {
const idx = this._indexDevour(caster);
if (idx < 0) return;
const run = this._devours[idx];
this._devours.splice(idx, 1);
this._releaseDevourVictims(run, true);
}
/** 中断全部吞噬 */
private _abortAllDevours(): void {
for (let i = this._devours.length - 1; i >= 0; i--) {
this._releaseDevourVictims(this._devours[i], true);
}
this._devours.length = 0;
}
/**
* 查找施法者吞噬。
* @param caster 施法者
*/
private _indexDevour(caster: EntityId): number {
for (let i = 0; i < this._devours.length; i++) {
if (this._devours[i].caster === caster) return i;
}
return -1;
}
/**
* 解除占用;restore 时还原缩放并解锁移动。
* @param run 本次吞噬
* @param restore 是否放回小兵
*/
private _releaseDevourVictims(run: DevourRun, restore: boolean): void {
const battle = this._battle;
for (let i = 0; i < run.victims.length; i++) {
const victim = run.victims[i];
const node = SkillPresentationEnv.getMonsterNode(victim.entity);
if (restore && node?.isValid) {
node.setScale(victim.scaleX, victim.scaleY, 1);
node.setWorldPosition(victim.fromX, victim.fromY, node.worldPosition.z);
}
SkillPresentationEnv.releaseMonsterVisual(victim.entity);
const move = battle?.session.world.getComponent(victim.entity, MovementComp);
if (move) move.presentLocked = false;
}
}
}