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.
2034 lines
75 KiB
2034 lines
75 KiB
|
3 weeks ago
|
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.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.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.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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|