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.
1242 lines
44 KiB
1242 lines
44 KiB
|
3 weeks ago
|
import { _decorator, Color, Component, isValid, Node, Sorting2D, sp, UITransform, v3, Vec3 } from 'cc';
|
||
|
|
import type { EntityId } from 'db://assets/mx/module/battle';
|
||
|
|
import { UnitMetaComp } from 'db://assets/mx/module/battle/ecs/Components';
|
||
|
|
import { GEvent } from 'db://assets/mx/module/event/GEvent';
|
||
|
|
import { GBundle, GPath } from '../../game/ConfigRes';
|
||
|
|
import { MonsterType } from '../../manager/MonsterDataManager';
|
||
|
|
import { HitEffectResolver, DEFAULT_HIT_EFFECT } from '../../battle/view/HitEffectResolver';
|
||
|
|
import { playHitLeanBack } from '../../battle/view/HitLeanBack';
|
||
|
|
import { playSpineHitFlash } from '../../battle/view/SpineHitFlash';
|
||
|
|
import { isParatroopBirdModel, MONSTER_ACCELERATE_FX_ANIM, MONSTER_ACCELERATE_FX_PREFAB, PARATROOP_LAND_ANIM } from '../../battle/mechanism/MechanismConstants';
|
||
|
|
import { ChapterLevelBuilder } from '../../battle/config/ChapterLevelBuilder';
|
||
|
|
import { traceCombatDamage } from 'db://assets/mx/tools/CombatDamageTrace';
|
||
|
|
import { MonsterBlood } from './MonsterBlood';
|
||
|
|
import { MonsterState } from './MonsterState';
|
||
|
|
import { BattleSpinePlayback } from '../../battle/presentation/BattleSpinePlayback';
|
||
|
|
import { getBattleTimeScale } from '../../battle/skill/BattlePresentationClock';
|
||
|
|
import { getMonsterStatusText, STATUS_TEXT_HP_CAP } from '../../battle/view/MonsterStatusText';
|
||
|
|
const { ccclass } = _decorator;
|
||
|
|
|
||
|
|
export { MonsterState } from './MonsterState';
|
||
|
|
|
||
|
|
/** 怪物标签(激光等武器占用标记) */
|
||
|
|
export enum MonsterTag {
|
||
|
|
IS_BE_CONTROL = 'IS_BE_CONTROL',
|
||
|
|
IS_BE_ATTACKED_BY_LASER = 'IS_BE_ATTACKED_BY_LASER',
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 血条 Sorting2D 层(与怪物皮肤同层) */
|
||
|
|
const BLOOD_BAR_SORT_LAYER = 3;
|
||
|
|
/** 血条底框 sortingOrder;进度子节点 +1,避免底框盖住填充 */
|
||
|
|
const BLOOD_BAR_SORT_ORDER = 8;
|
||
|
|
/** 还魂丹复活特效(battleHero/prefab/skill/小怪复活效果) */
|
||
|
|
const REVIVE_FX_PREFAB = '小怪复活效果';
|
||
|
|
/** 还魂丹复活 Spine 动画 */
|
||
|
|
const REVIVE_FX_ANIM = '小怪复活效果';
|
||
|
|
|
||
|
|
/** 怪物视图组件(配合 monsterTemplate.prefab,逻辑层由 MX BattleSession 驱动) */
|
||
|
|
@ccclass('Monster')
|
||
|
|
export class Monster extends Component {
|
||
|
|
private static readonly _hitFx = new HitEffectResolver();
|
||
|
|
|
||
|
|
downEff: Node = null;
|
||
|
|
upEff: Node = null;
|
||
|
|
skin: Node = null;
|
||
|
|
bloodPos: Node = null;
|
||
|
|
anim: sp.Skeleton = null;
|
||
|
|
boold: MonsterBlood = null;
|
||
|
|
|
||
|
|
/** 机制单位:不可移动、不寻路、不攻墙 */
|
||
|
|
mechanismImmobile = false;
|
||
|
|
|
||
|
|
/** MX 实体 id(由 Presenter 赋值) */
|
||
|
|
entityId: EntityId | null = null;
|
||
|
|
|
||
|
|
private _speed = 0;
|
||
|
|
private _bornPos: Vec3 = null;
|
||
|
|
private _atkTarget: Node = null;
|
||
|
|
private _lastAttackTime = 0;
|
||
|
|
/** 复活回出生点后等待按间隔放行 */
|
||
|
|
private _reviveHeld = false;
|
||
|
|
|
||
|
|
/** 由 MX MovementSystem 驱动位移时为 true */
|
||
|
|
useMxMovement = false;
|
||
|
|
|
||
|
|
state: MonsterState = MonsterState.MOVING_FORWARD;
|
||
|
|
Data: ITableMonster = null;
|
||
|
|
/** 相对 Monster.size 的表现倍率(营召唤 1.3) */
|
||
|
|
sizeMul = 1;
|
||
|
|
hp = 0;
|
||
|
|
maxHp = 0;
|
||
|
|
IsDead = false;
|
||
|
|
|
||
|
|
private _tagStatus = new Map<string, boolean>();
|
||
|
|
/** 头顶眩晕特效节点(怪_晕眩) */
|
||
|
|
private _stunFx: Node | null = null;
|
||
|
|
private _stunFxSeq = 0;
|
||
|
|
/** 减速冰霜特效节点(怪_冰) */
|
||
|
|
private _slowFx: Node | null = null;
|
||
|
|
private _slowFxSeq = 0;
|
||
|
|
/** 点燃火焰特效节点(怪_火) */
|
||
|
|
private _burnFx: Node | null = null;
|
||
|
|
private _burnFxSeq = 0;
|
||
|
|
/** 流血特效节点(怪_流血) */
|
||
|
|
private _bleedFx: Node | null = null;
|
||
|
|
private _bleedFxSeq = 0;
|
||
|
|
/** 恶鬼啃食特效节点(怪_恶鬼) */
|
||
|
|
private _ghostBiteFx: Node | null = null;
|
||
|
|
private _ghostBiteFxSeq = 0;
|
||
|
|
/** 感电特效节点(怪_电) */
|
||
|
|
private _elecFx: Node | null = null;
|
||
|
|
private _elecFxSeq = 0;
|
||
|
|
/** 加速特效节点(怪_加速) */
|
||
|
|
private _accelerateFx: Node | null = null;
|
||
|
|
/** 加速特效朝向用:上一帧世界坐标 */
|
||
|
|
private _accelFxLastX = Number.NaN;
|
||
|
|
private _accelFxLastY = Number.NaN;
|
||
|
|
/** playSpine 传入的基础倍速(不含 BattleRuntime.TimeScale) */
|
||
|
|
private _animBaseTimeScale = 1;
|
||
|
|
|
||
|
|
checkTag(tag: MonsterTag | string): boolean {
|
||
|
|
return this._tagStatus.get(String(tag)) ?? false;
|
||
|
|
}
|
||
|
|
|
||
|
|
setTag(tag: MonsterTag | string, value: boolean): void {
|
||
|
|
this._tagStatus.set(String(tag), value);
|
||
|
|
}
|
||
|
|
|
||
|
|
protected onLoad(): void {
|
||
|
|
this.downEff = this.node.find('downEff');
|
||
|
|
this.upEff = this.node.find('upEff');
|
||
|
|
this.skin = this.node.find('skin');
|
||
|
|
this.bloodPos = this.node.find('bloodPos');
|
||
|
|
}
|
||
|
|
|
||
|
|
setData(data: ITableMonster, hp: number) {
|
||
|
|
this.Data = data;
|
||
|
|
this.sizeMul = 1;
|
||
|
|
this._speed = this.Data.speed;
|
||
|
|
this.IsDead = false;
|
||
|
|
this.hp = hp;
|
||
|
|
this.maxHp = hp;
|
||
|
|
this.useMxMovement = !!gg.game.CurentBattle?.useMxMovement;
|
||
|
|
|
||
|
|
this.init();
|
||
|
|
this._lastAttackTime = 0;
|
||
|
|
if (this.mechanismImmobile) {
|
||
|
|
this.state = MonsterState.STOP;
|
||
|
|
} else {
|
||
|
|
this.playWalk();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
protected onEnable(): void {
|
||
|
|
GEvent.Ins.on(GEvent.BattleTimeScaleChanged, this._syncAnimBattleTimeScale, this);
|
||
|
|
this._syncAnimBattleTimeScale();
|
||
|
|
}
|
||
|
|
|
||
|
|
protected onDisable(): void {
|
||
|
|
GEvent.Ins.off(GEvent.BattleTimeScaleChanged, this._syncAnimBattleTimeScale, this);
|
||
|
|
}
|
||
|
|
|
||
|
|
protected update(_dt: number): void {
|
||
|
|
if (!this._accelerateFx?.isValid || !this._accelerateFx.active) return;
|
||
|
|
const battle = gg.game.CurentBattle;
|
||
|
|
if (!battle || battle.IsGameOver || battle.IsGamePause) return;
|
||
|
|
this._tickAccelerateFxAngle();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 战斗 TimeScale 变化时同步 Spine 播放倍速 */
|
||
|
|
private _syncAnimBattleTimeScale(): void {
|
||
|
|
if (!this.anim?.isValid || this.anim.animation === '死亡') return;
|
||
|
|
const battleScale = gg.game.CurentBattle?.TimeScale ?? 1;
|
||
|
|
const want = this._animBaseTimeScale * battleScale;
|
||
|
|
if (Math.abs(this.anim.timeScale - want) > 1e-4) {
|
||
|
|
this.anim.timeScale = want;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 当前皮肤预制体名(icon / name) */
|
||
|
|
private _resolveSkinPrefabName(): string {
|
||
|
|
if (!this.Data) return '';
|
||
|
|
return ChapterLevelBuilder.resolveMonsterPrefabFromRow(this.Data)
|
||
|
|
?? String(this.Data.icon ?? this.Data.name ?? '').trim();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 进入等待复活:停止攻击计时与攻击动画 */
|
||
|
|
onEnterWaitRevive(): void {
|
||
|
|
this._lastAttackTime = 0;
|
||
|
|
this.state = MonsterState.STOP;
|
||
|
|
if (this.Data?.type === MonsterType.TreasureBox) return;
|
||
|
|
this.playSpine('待机');
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 复活后回到出生点并隐藏,等按出怪间隔跳出再放行 */
|
||
|
|
holdAtBornAfterRevive(atWorld?: Vec3): void {
|
||
|
|
const pos = atWorld ?? this._bornPos;
|
||
|
|
if (!pos) return;
|
||
|
|
this.node.worldPosition = pos.clone();
|
||
|
|
this.node.opacity = 0;
|
||
|
|
this.state = MonsterState.STOP;
|
||
|
|
this._reviveHeld = true;
|
||
|
|
this._lastAttackTime = 0;
|
||
|
|
if (this.Data?.type !== MonsterType.TreasureBox) {
|
||
|
|
this.playSpine('待机');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 复活放行:恢复寻路与行走表现 */
|
||
|
|
releaseFromReviveQueue(): void {
|
||
|
|
if (!this._reviveHeld) return;
|
||
|
|
this._reviveHeld = false;
|
||
|
|
this.state = MonsterState.PATHFINDING;
|
||
|
|
this._lastAttackTime = 0;
|
||
|
|
this.resolveAttackTarget();
|
||
|
|
this.playWalk();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 复活后回到出生点(立即寻路,仅供非 MX 旧路径) */
|
||
|
|
teleportToBornPos(): void {
|
||
|
|
if (!this._bornPos) return;
|
||
|
|
this.node.worldPosition = this._bornPos.clone();
|
||
|
|
this.state = MonsterState.PATHFINDING;
|
||
|
|
this._lastAttackTime = 0;
|
||
|
|
this.resolveAttackTarget();
|
||
|
|
this.playWalk();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 绑定攻击目标(须在 MapGridsReady 之后) */
|
||
|
|
bindPathTarget(): void {
|
||
|
|
if (this.mechanismImmobile) return;
|
||
|
|
const map = gg.game.CurentBattle?.Map;
|
||
|
|
if (map?.gridsInitialized) {
|
||
|
|
this.resolveAttackTarget();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const onReady = () => {
|
||
|
|
GEvent.Ins.off(GEvent.MapGridsReady, onReady, this);
|
||
|
|
if (!this.node?.isValid || this.IsDead) return;
|
||
|
|
this.resolveAttackTarget();
|
||
|
|
};
|
||
|
|
GEvent.Ins.on(GEvent.MapGridsReady, onReady, this);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 空投等机制怪:落地后更新复活/芭蕉扇回收点(非出怪口) */
|
||
|
|
updateLandingBornPos(pos: Vec3): void {
|
||
|
|
this._bornPos = pos.clone();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 空投落地出生点(世界坐标);未设置时返回 null */
|
||
|
|
getLandingBornPos(): Vec3 | null {
|
||
|
|
return this._bornPos?.clone() ?? null;
|
||
|
|
}
|
||
|
|
|
||
|
|
init() {
|
||
|
|
this.state = MonsterState.PATHFINDING;
|
||
|
|
this._bornPos = this.node.worldPosition.clone();
|
||
|
|
|
||
|
|
const animNode = this.skin?.find('anim');
|
||
|
|
this.anim = animNode?.getComponent(sp.Skeleton) || this.skin?.getComponentInChildren(sp.Skeleton);
|
||
|
|
this.syncBloodPosToSkin();
|
||
|
|
this.ensureBloodBar();
|
||
|
|
this.scheduleOnce(() => this.syncBloodPosToSkin(), 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 创建/刷新血条(皮肤异步加载后需再次调用以校正 bloodPos) */
|
||
|
|
ensureBloodBar(): void {
|
||
|
|
this.syncBloodPosToSkin();
|
||
|
|
if (!this.bloodPos?.isValid || !this.Data || this.Data.type === MonsterType.TreasureBox) return;
|
||
|
|
|
||
|
|
const prefabName = this._bloodPrefabName(this.Data.type);
|
||
|
|
const poolKey = this._bloodPoolKey(prefabName);
|
||
|
|
|
||
|
|
if (this.boold?.isValid) {
|
||
|
|
const attachedKey = this.boold.node['resKey'] as string | undefined;
|
||
|
|
if (attachedKey !== poolKey) {
|
||
|
|
gg.res.putNode(this.boold.node);
|
||
|
|
this.boold = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const shouldShow = this.Data.type !== MonsterType.SmallMonster
|
||
|
|
|| this.hp < this.maxHp;
|
||
|
|
|
||
|
|
if (!this.boold) {
|
||
|
|
const booldNode = gg.res.getNode('prefab/blood/', prefabName, GBundle.BattleGameMonster);
|
||
|
|
if (!booldNode?.isValid) {
|
||
|
|
console.log('[Monster] blood prefab missing', prefabName);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
booldNode.active = false;
|
||
|
|
booldNode.setParent(this.bloodPos);
|
||
|
|
this._applyBloodBarScale(booldNode);
|
||
|
|
this.boold = booldNode.getComponent(MonsterBlood);
|
||
|
|
} else {
|
||
|
|
this._applyBloodBarScale(this.boold.node);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (this.boold?.node?.isValid) {
|
||
|
|
this.boold.node.active = shouldShow;
|
||
|
|
this.boold.Data = this.Data;
|
||
|
|
this.boold.updateBloodBar(this.hp, this.maxHp);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private _bloodPoolKey(prefabName: string): string {
|
||
|
|
return GBundle.BattleGameMonster + 'prefab/blood/' + prefabName;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 非 Boss 保持 1;Boss 缩小,并抬血条绘制顺序 */
|
||
|
|
private _applyBloodBarScale(booldNode: Node): void {
|
||
|
|
if (this.Data?.type === MonsterType.Boss) {
|
||
|
|
booldNode.setScale(0.7, 0.7, 1);
|
||
|
|
} else {
|
||
|
|
booldNode.setScale(1, 1, 1);
|
||
|
|
}
|
||
|
|
this._liftBloodBarSorting(booldNode);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 血条与皮肤同层;进度 order 高于底框,避免编辑器改 layer 后填充被盖住 */
|
||
|
|
private _liftBloodBarSorting(root: Node): void {
|
||
|
|
const sorts = root.getComponentsInChildren(Sorting2D);
|
||
|
|
for (let i = 0; i < sorts.length; i++) {
|
||
|
|
const s = sorts[i];
|
||
|
|
s.sortingLayer = BLOOD_BAR_SORT_LAYER;
|
||
|
|
s.sortingOrder = s.node === root ? BLOOD_BAR_SORT_ORDER : BLOOD_BAR_SORT_ORDER + 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 用 Sorting2D 按脚底 Y 叠层,避免 setSiblingIndex 打散合批。
|
||
|
|
* @returns 皮肤 sortingOrder
|
||
|
|
*/
|
||
|
|
syncYSortOrder(): number {
|
||
|
|
let order = Math.round(-this.node.worldPosition.y / 16) * 4;
|
||
|
|
if (order < -32000) order = -32000;
|
||
|
|
else if (order > 32000) order = 32000;
|
||
|
|
if (this.skin?.isValid) {
|
||
|
|
const sorts = this.skin.getComponentsInChildren(Sorting2D);
|
||
|
|
for (let i = 0; i < sorts.length; i++) {
|
||
|
|
sorts[i].sortingOrder = order;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const blood = this.boold?.node;
|
||
|
|
if (blood?.isValid) {
|
||
|
|
const sorts = blood.getComponentsInChildren(Sorting2D);
|
||
|
|
for (let i = 0; i < sorts.length; i++) {
|
||
|
|
const s = sorts[i];
|
||
|
|
s.sortingLayer = BLOOD_BAR_SORT_LAYER;
|
||
|
|
s.sortingOrder = s.node === blood ? order + 1 : order + 2;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return order;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 行军小怪:须在 setSkin 之后调用,进入 PRIVATE_CACHE */
|
||
|
|
applyMarchSpineCache(): void {
|
||
|
|
const sk = this.anim;
|
||
|
|
if (!sk?.isValid) return;
|
||
|
|
const t = this.Data?.type;
|
||
|
|
if (t !== MonsterType.SmallMonster && t !== MonsterType.SummonMonster) return;
|
||
|
|
if (typeof sk.setAnimationCacheMode !== 'function') return;
|
||
|
|
if (typeof sk.isAnimationCached === 'function' && sk.isAnimationCached()) {
|
||
|
|
sk.invalidAnimationCache?.();
|
||
|
|
} else {
|
||
|
|
sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.PRIVATE_CACHE);
|
||
|
|
}
|
||
|
|
const batchSk = sk as sp.Skeleton & { enableBatch?: boolean };
|
||
|
|
if (batchSk.enableBatch !== undefined) {
|
||
|
|
batchSk.enableBatch = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private _bloodPrefabName(type: number): string {
|
||
|
|
if (type === MonsterType.Boss) return 'BOSS血条底框';
|
||
|
|
if (type === MonsterType.SmallMonster || type === MonsterType.SummonMonster) return '小怪血条底';
|
||
|
|
return 'MidBOSS血条框';
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 按碰撞盒或 Spine 包围盒顶对齐 bloodPos(皮肤加载后需再调一次) */
|
||
|
|
syncBloodPosToSkin(): void {
|
||
|
|
if (!this.bloodPos?.isValid || !this.skin?.isValid) return;
|
||
|
|
|
||
|
|
const scaleY = Math.max(0.01, Number(this.Data?.size) || 1);
|
||
|
|
const useSpine = this._usesSpineBloodAnchor();
|
||
|
|
const spineTopLocalY = useSpine ? this._resolveSpineTopLocalY() : null;
|
||
|
|
const paddingWorld = this._bloodTopPaddingWorld(useSpine && spineTopLocalY != null);
|
||
|
|
|
||
|
|
let y: number;
|
||
|
|
if (spineTopLocalY != null) {
|
||
|
|
y = spineTopLocalY + paddingWorld / scaleY;
|
||
|
|
} else {
|
||
|
|
const ut = this.node.getComponent(UITransform);
|
||
|
|
if (!ut) return;
|
||
|
|
y = ut.contentSize.height * (1 - ut.anchorPoint.y) + paddingWorld / scaleY;
|
||
|
|
}
|
||
|
|
|
||
|
|
const skinPrefab = this._resolveSkinPrefabName();
|
||
|
|
if (skinPrefab === '空投怪物箱子' || skinPrefab === '伞兵'
|
||
|
|
|| skinPrefab === '女巫丧尸(加血)') {
|
||
|
|
y -= 120;
|
||
|
|
}
|
||
|
|
this.bloodPos.setPosition(0, y, 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 召唤怪与 Boss/中期 Boss 用 Spine 顶对齐血条(碰撞盒常高于实际网格) */
|
||
|
|
private _usesSpineBloodAnchor(): boolean {
|
||
|
|
const t = this.Data?.type;
|
||
|
|
return t === MonsterType.SummonMonster
|
||
|
|
|| t === MonsterType.Boss
|
||
|
|
|| t === MonsterType.MidBoss
|
||
|
|
|| t === MonsterType.SilverBoss
|
||
|
|
|| t === MonsterType.GoldenBoss
|
||
|
|
|| t === MonsterType.OrangeBoss;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Spine 顶边转到怪物根本地 Y。沿父链用本地坐标,避免出场 scale=0 时世界变换把顶量到脚底。
|
||
|
|
*/
|
||
|
|
private _resolveSpineTopLocalY(): number | null {
|
||
|
|
const anim = this.anim;
|
||
|
|
if (!anim?.isValid) return null;
|
||
|
|
const topInAnim = this._spineTopInAnimLocal(anim);
|
||
|
|
if (topInAnim == null) return null;
|
||
|
|
return this._toRootLocalY(anim.node, topInAnim);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* anim 节点本地顶边 Y(相对节点原点)。UITransform 常小于网格(如哮天犬盒 89、骨骼 126),取较高者。
|
||
|
|
* @param anim Spine
|
||
|
|
*/
|
||
|
|
private _spineTopInAnimLocal(anim: sp.Skeleton): number | null {
|
||
|
|
const animUt = anim.node.getComponent(UITransform);
|
||
|
|
if (!animUt) return null;
|
||
|
|
let top = 0;
|
||
|
|
const height = animUt.contentSize.height;
|
||
|
|
if (height > 1) {
|
||
|
|
top = height * (1 - animUt.anchorPoint.y);
|
||
|
|
}
|
||
|
|
const aabbTop = this._skeletonAabbTopLocal(anim);
|
||
|
|
if (aabbTop != null && aabbTop > top) top = aabbTop;
|
||
|
|
if (!(top > 0) && !(height > 1)) return null;
|
||
|
|
return top;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Spine 绑定姿态 AABB 顶(anim 本地 Y)。
|
||
|
|
* @param anim Spine
|
||
|
|
*/
|
||
|
|
private _skeletonAabbTopLocal(anim: sp.Skeleton): number | null {
|
||
|
|
const data = anim.skeletonData?.getRuntimeData(true);
|
||
|
|
if (!data || !(data.height > 0)) return null;
|
||
|
|
return data.y + data.height;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 子节点本地 Y → 怪物根本地 Y(不含根缩放)。
|
||
|
|
* @param fromNode 子节点
|
||
|
|
* @param localY 该节点本地 Y
|
||
|
|
*/
|
||
|
|
private _toRootLocalY(fromNode: Node, localY: number): number | null {
|
||
|
|
let y = localY;
|
||
|
|
let n: Node | null = fromNode;
|
||
|
|
while (n && n !== this.node) {
|
||
|
|
y = n.position.y + y * n.scale.y;
|
||
|
|
n = n.parent;
|
||
|
|
}
|
||
|
|
if (n !== this.node) {
|
||
|
|
console.log('[Monster] 血条 Spine 不在根下', fromNode.name);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return y;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 血条相对锚点顶的额外间距(世界像素;负值=压进碰撞盒内,拉近与怪的距离) */
|
||
|
|
private _bloodTopPaddingWorld(onSpineTop: boolean): number {
|
||
|
|
if (onSpineTop) return 2;
|
||
|
|
switch (this.Data?.type) {
|
||
|
|
// 小怪字未占满碰撞盒/格子,贴盒顶会悬空 → 压到字顶附近
|
||
|
|
case MonsterType.SmallMonster:
|
||
|
|
return -36;
|
||
|
|
case MonsterType.Boss:
|
||
|
|
return 18;
|
||
|
|
case MonsterType.MidBoss:
|
||
|
|
case MonsterType.SilverBoss:
|
||
|
|
case MonsterType.GoldenBoss:
|
||
|
|
case MonsterType.OrangeBoss:
|
||
|
|
return 8;
|
||
|
|
case MonsterType.SummonMonster:
|
||
|
|
return 8;
|
||
|
|
default:
|
||
|
|
return 2;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 解析攻击城墙目标节点(按出怪口阵营匹配对应攻击点) */
|
||
|
|
resolveAttackTarget(): void {
|
||
|
|
if (this.mechanismImmobile) {
|
||
|
|
this._atkTarget = null;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const map = gg.game.CurentBattle?.Map;
|
||
|
|
if (!map || !isValid(map)) return;
|
||
|
|
let spawnPort: number | undefined;
|
||
|
|
const battle = gg.game.CurentBattle;
|
||
|
|
if (this.entityId != null && battle?.session) {
|
||
|
|
const meta = battle.session.world.getComponent(this.entityId, UnitMetaComp);
|
||
|
|
if (meta?.spawnPoint && meta.spawnPoint >= 1) {
|
||
|
|
spawnPort = meta.spawnPoint;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
this._atkTarget = map.getPathShortestTarget(this.node.worldPosition, spawnPort);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 障碍变更后重新解析攻击目标 */
|
||
|
|
getAtkTarget(): void {
|
||
|
|
this.resolveAttackTarget();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** MX 实体剩余路径距离(逻辑像素) */
|
||
|
|
getRemainPathDistance(): number {
|
||
|
|
const battle = gg.game.CurentBattle;
|
||
|
|
if (this.entityId == null || !battle?.session) return 0;
|
||
|
|
return battle.session.movement.getRemainingPathLength(this.entityId);
|
||
|
|
}
|
||
|
|
|
||
|
|
getMoveSpeed(): number {
|
||
|
|
return this._speed || this.Data?.speed || 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
getIsDead() {
|
||
|
|
return this.IsDead;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 同步 MX 层 HP 并刷新血条(由 CocosBattleMonsterPresenter 在 HitAfter/DamageDealt 时调用) */
|
||
|
|
syncMxHp(hp: number, maxHp: number): void {
|
||
|
|
const prevHp = this.hp;
|
||
|
|
this.hp = Math.max(0, hp);
|
||
|
|
this.maxHp = Math.max(1, maxHp);
|
||
|
|
if (!this.boold?.isValid) {
|
||
|
|
this.ensureBloodBar();
|
||
|
|
}
|
||
|
|
if (this.boold?.node?.isValid) {
|
||
|
|
this.boold.Data = this.Data;
|
||
|
|
this.boold.updateBloodBar(this.hp, this.maxHp);
|
||
|
|
} else {
|
||
|
|
traceCombatDamage('syncMxHpNoBlood', {
|
||
|
|
entityId: this.entityId,
|
||
|
|
hp,
|
||
|
|
maxHp,
|
||
|
|
prevHp,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 女巫机制回血:播放加血特效并刷新血条(血量须已由 ECS/syncMxHp 同步)
|
||
|
|
* @param hp 加血值;isCoefficient 为 true 时按 maxHp 系数计算
|
||
|
|
*/
|
||
|
|
addHP(hp: number, isCoefficient: boolean): void {
|
||
|
|
if (isCoefficient) {
|
||
|
|
hp = Math.floor(this.maxHp * hp);
|
||
|
|
}
|
||
|
|
this.hp = Math.min(this.maxHp, this.hp + hp);
|
||
|
|
if (this.boold) {
|
||
|
|
this.boold.updateBloodBar(this.hp, this.maxHp);
|
||
|
|
}
|
||
|
|
this._playAddHpEffect('加血');
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 加血特效(一份预制体,内含上下层) */
|
||
|
|
private _addHpEff: Node | null = null;
|
||
|
|
/** 血上限特效节点(血量上限) */
|
||
|
|
private _hpCapEff: Node | null = null;
|
||
|
|
/** 还魂丹复活特效 */
|
||
|
|
private _reviveFx: Node | null = null;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 女巫机制回血特效(血量须已由 syncMxHp 同步)。
|
||
|
|
* @param statusText 状态飘字;默认「加血」
|
||
|
|
*/
|
||
|
|
playHealEffect(statusText = '加血'): void {
|
||
|
|
if (this._hpCapEff?.isValid) return;
|
||
|
|
this._playAddHpEffect(statusText);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 血上限提升:播 `血量上限` 并飘「血上限」,不播 `怪物加血`。
|
||
|
|
*/
|
||
|
|
playHpCapEffect(): void {
|
||
|
|
if (!this.upEff?.isValid) return;
|
||
|
|
this.clearAddHpEff();
|
||
|
|
this.clearHpCapEff();
|
||
|
|
let node: Node | null = null;
|
||
|
|
try {
|
||
|
|
node = gg.res.getNode('prefab/', '血量上限', GBundle.BattleGameMonster);
|
||
|
|
} catch {
|
||
|
|
console.warn('[Monster] 血量上限取节点失败');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!node?.isValid) {
|
||
|
|
console.warn('[Monster] 血量上限预制体缺失');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
node.setParent(this.upEff);
|
||
|
|
node.setPosition(Vec3.ZERO);
|
||
|
|
node.active = true;
|
||
|
|
this._hpCapEff = node;
|
||
|
|
for (const sk of node.getComponentsInChildren(sp.Skeleton)) {
|
||
|
|
sk.setAnimation(0, '加血量上限', false);
|
||
|
|
}
|
||
|
|
this.scheduleOnce(this._onHpCapEffTimeout, 2);
|
||
|
|
if (this.entityId != null) {
|
||
|
|
getMonsterStatusText().play(this.entityId, STATUS_TEXT_HP_CAP);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 回收血上限特效 */
|
||
|
|
clearHpCapEff(): void {
|
||
|
|
this.unschedule(this._onHpCapEffTimeout);
|
||
|
|
if (this._hpCapEff?.isValid) gg.res.putNode(this._hpCapEff);
|
||
|
|
this._hpCapEff = null;
|
||
|
|
this._sweepEffByName(this.upEff, '血量上限');
|
||
|
|
}
|
||
|
|
|
||
|
|
private _onHpCapEffTimeout = (): void => {
|
||
|
|
this.clearHpCapEff();
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 还魂丹原地复活:在死亡点播 `小怪复活效果`(不跟随怪物)。
|
||
|
|
* @param fxParent 特效父节点(怪物层)
|
||
|
|
* @returns 特效动画时长(秒);失败为 0
|
||
|
|
*/
|
||
|
|
playReviveEffect(fxParent?: Node): number {
|
||
|
|
return this._playReviveFx(fxParent ?? null);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 出池 `小怪复活效果`,钉在死亡世界坐标,播完回收。
|
||
|
|
* @param fxParent 特效父节点
|
||
|
|
* @returns 动画时长(秒)
|
||
|
|
*/
|
||
|
|
private _playReviveFx(fxParent: Node | null): number {
|
||
|
|
this.clearReviveFx();
|
||
|
|
if (!fxParent?.isValid) {
|
||
|
|
console.warn('[Monster] 小怪复活效果缺少父节点');
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
if (!this.node?.isValid) {
|
||
|
|
console.warn('[Monster] 小怪复活效果缺少怪物节点');
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
let node: Node | null = null;
|
||
|
|
try {
|
||
|
|
node = gg.res.getNode(GPath.HeroSkillPrefab(), REVIVE_FX_PREFAB, GBundle.BattleHero);
|
||
|
|
} catch {
|
||
|
|
console.warn('[Monster] 小怪复活效果取节点失败');
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
if (!node?.isValid) {
|
||
|
|
console.warn('[Monster] 小怪复活效果预制体缺失');
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
node.setParent(fxParent);
|
||
|
|
const wp = this.node.worldPosition;
|
||
|
|
node.setWorldPosition(wp.x, wp.y, wp.z);
|
||
|
|
node.active = true;
|
||
|
|
this._reviveFx = node;
|
||
|
|
let wait = 0;
|
||
|
|
const sks = node.getComponentsInChildren(sp.Skeleton);
|
||
|
|
for (let i = 0; i < sks.length; i++) {
|
||
|
|
const sk = sks[i];
|
||
|
|
BattleSpinePlayback.play(sk, REVIVE_FX_ANIM, { loop: false });
|
||
|
|
const dur = BattleSpinePlayback.getAnimDuration(sk, REVIVE_FX_ANIM);
|
||
|
|
if (dur != null && dur > wait) wait = dur;
|
||
|
|
}
|
||
|
|
if (wait <= 0) {
|
||
|
|
console.warn('[Monster] 小怪复活效果动画时长无效,按 1.5s 回收');
|
||
|
|
wait = 1.5;
|
||
|
|
}
|
||
|
|
const ts = Math.max(0.01, getBattleTimeScale());
|
||
|
|
this.scheduleOnce(this._onReviveFxTimeout, wait / ts);
|
||
|
|
return wait;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 回收还魂复活特效 */
|
||
|
|
clearReviveFx(): void {
|
||
|
|
this.unschedule(this._onReviveFxTimeout);
|
||
|
|
if (this._reviveFx?.isValid) gg.res.putNode(this._reviveFx);
|
||
|
|
this._reviveFx = null;
|
||
|
|
this._sweepEffByName(this.downEff, REVIVE_FX_PREFAB);
|
||
|
|
}
|
||
|
|
|
||
|
|
private _onReviveFxTimeout = (): void => {
|
||
|
|
this.clearReviveFx();
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 播加血 Spine 与状态飘字。预制体已含上下层,只出一份,播完回收。
|
||
|
|
* @param statusText 头顶飘字文案
|
||
|
|
*/
|
||
|
|
private _playAddHpEffect(statusText: string): void {
|
||
|
|
if (!this.upEff?.isValid) return;
|
||
|
|
this.clearAddHpEff();
|
||
|
|
let node: Node | null = null;
|
||
|
|
try {
|
||
|
|
node = gg.res.getNode('prefab/', '怪物加血', GBundle.BattleGameMonster);
|
||
|
|
} catch {
|
||
|
|
console.warn('[Monster] 怪物加血取节点失败');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!node?.isValid) {
|
||
|
|
console.warn('[Monster] 怪物加血预制体缺失');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
node.setParent(this.upEff);
|
||
|
|
node.setPosition(Vec3.ZERO);
|
||
|
|
node.active = true;
|
||
|
|
this._addHpEff = node;
|
||
|
|
let wait = 0;
|
||
|
|
const sks = node.getComponentsInChildren(sp.Skeleton);
|
||
|
|
for (let i = 0; i < sks.length; i++) {
|
||
|
|
const sk = sks[i];
|
||
|
|
const anim = String((sk as sp.Skeleton & { defaultAnimation?: string }).defaultAnimation || '').trim();
|
||
|
|
if (!anim) continue;
|
||
|
|
sk.setAnimation(0, anim, false);
|
||
|
|
const dur = BattleSpinePlayback.getAnimDuration(sk, anim);
|
||
|
|
if (dur != null && dur > wait) wait = dur;
|
||
|
|
}
|
||
|
|
this.scheduleOnce(this._onAddHpEffTimeout, wait > 0 ? wait : 2);
|
||
|
|
if (this.entityId != null) {
|
||
|
|
getMonsterStatusText().play(this.entityId, statusText);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 回收加血特效,并清掉 upEff 上残留的「怪物加血」 */
|
||
|
|
clearAddHpEff(): void {
|
||
|
|
this.unschedule(this._onAddHpEffTimeout);
|
||
|
|
if (this._addHpEff?.isValid) gg.res.putNode(this._addHpEff);
|
||
|
|
this._addHpEff = null;
|
||
|
|
this._sweepEffByName(this.upEff, '怪物加血');
|
||
|
|
}
|
||
|
|
|
||
|
|
private _onAddHpEffTimeout = (): void => {
|
||
|
|
this.clearAddHpEff();
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 回收挂点上指定名的残留特效。
|
||
|
|
* @param parent 挂点
|
||
|
|
* @param name 子节点名
|
||
|
|
*/
|
||
|
|
private _sweepEffByName(parent: Node | null, name: string): void {
|
||
|
|
if (!parent?.isValid) return;
|
||
|
|
const kids = parent.children;
|
||
|
|
for (let i = kids.length - 1; i >= 0; i--) {
|
||
|
|
const child = kids[i];
|
||
|
|
if (!child?.isValid || child.name !== name) continue;
|
||
|
|
gg.res.putNode(child);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 皮肤基准 tint(受击闪白后还原,避免连击锁色) */
|
||
|
|
private _baseAnimColor = new Color(255, 255, 255, 255);
|
||
|
|
|
||
|
|
/** 记录皮肤基准色(挂皮肤 / 改色后调用) */
|
||
|
|
setBaseAnimColor(c: Readonly<Color>): void {
|
||
|
|
this._baseAnimColor.set(c);
|
||
|
|
if (this.anim?.isValid) this.anim.color = this._baseAnimColor;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 播放受击反馈:闪白 + 按来击方向斜切后仰;连击不得把闪白中间色当成还原色 */
|
||
|
|
playHit(_damageType?: string, hitDirX = 0, hitDirY = 0): void {
|
||
|
|
if (!this.anim || !this.node?.isValid) return;
|
||
|
|
if (this.IsDead) return;
|
||
|
|
|
||
|
|
playSpineHitFlash(this.anim, this._baseAnimColor);
|
||
|
|
|
||
|
|
const leanTarget = this.skin?.isValid ? this.skin : this.node;
|
||
|
|
playHitLeanBack(leanTarget, hitDirX, hitDirY);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 对象中心播放受击 Spine 特效(配置来自 Weapon1.hitEffect) */
|
||
|
|
showHitEffect(hitEffectCfg?: string, faceSourceX = 0, faceSourceY = 0): void {
|
||
|
|
if (!this.upEff?.isValid || this.IsDead || !this.anim?.isValid) return;
|
||
|
|
const cfg = hitEffectCfg?.trim() || DEFAULT_HIT_EFFECT;
|
||
|
|
Monster._hitFx.play(cfg, this.upEff, this.getPosByAtkPosIndex(2), faceSourceX, faceSourceY);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 显示眩晕特效(prefab/怪_晕眩),挂在 upEff,按时长自动回收;刷新则重置计时。
|
||
|
|
*/
|
||
|
|
showStunFx(durationSec: number): void {
|
||
|
|
if (!this.node?.isValid || this.IsDead) return;
|
||
|
|
const parent = this.upEff?.isValid ? this.upEff : this.node;
|
||
|
|
++this._stunFxSeq;
|
||
|
|
|
||
|
|
if (!this._stunFx?.isValid) {
|
||
|
|
let node: Node | null = null;
|
||
|
|
try {
|
||
|
|
node = gg.res.getNode('prefab/', '怪_晕眩', GBundle.BattleGameMonster);
|
||
|
|
} catch {
|
||
|
|
console.log('[Monster] stun fx prefab getNode failed');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!node?.isValid) {
|
||
|
|
console.log('[Monster] stun fx prefab missing: 怪_晕眩');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
node.parent = parent;
|
||
|
|
node.setPosition(0, 0, 0);
|
||
|
|
node.active = true;
|
||
|
|
this._stunFx = node;
|
||
|
|
const sk = node.getComponentInChildren(sp.Skeleton);
|
||
|
|
if (sk) {
|
||
|
|
sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.SHARED_CACHE);
|
||
|
|
sk.setAnimation(0, 'animation', true);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const dur = Math.max(0.05, durationSec);
|
||
|
|
this.unschedule(this._onStunFxTimeout);
|
||
|
|
this.scheduleOnce(this._onStunFxTimeout, dur);
|
||
|
|
}
|
||
|
|
|
||
|
|
clearStunFx(): void {
|
||
|
|
this._stunFxSeq++;
|
||
|
|
this.unschedule(this._onStunFxTimeout);
|
||
|
|
if (this._stunFx?.isValid) {
|
||
|
|
gg.res.putNode(this._stunFx);
|
||
|
|
}
|
||
|
|
this._stunFx = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
private _onStunFxTimeout = (): void => {
|
||
|
|
this.clearStunFx();
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 显示减速特效(prefab/怪_冰),挂在 upEff,按时长自动回收;刷新则重置计时。
|
||
|
|
*/
|
||
|
|
showSlowFx(durationSec: number): void {
|
||
|
|
if (!this.node?.isValid || this.IsDead) return;
|
||
|
|
const parent = this.upEff?.isValid ? this.upEff : this.node;
|
||
|
|
++this._slowFxSeq;
|
||
|
|
|
||
|
|
if (!this._slowFx?.isValid) {
|
||
|
|
let node: Node | null = null;
|
||
|
|
try {
|
||
|
|
node = gg.res.getNode('prefab/', '怪_冰', GBundle.BattleGameMonster);
|
||
|
|
} catch {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!node?.isValid) return;
|
||
|
|
node.parent = parent;
|
||
|
|
node.setPosition(0, 0, 0);
|
||
|
|
node.active = true;
|
||
|
|
this._slowFx = node;
|
||
|
|
const sk = node.getComponentInChildren(sp.Skeleton);
|
||
|
|
if (sk) {
|
||
|
|
sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.SHARED_CACHE);
|
||
|
|
sk.setAnimation(0, 'animation', true);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const dur = Math.max(0.05, durationSec);
|
||
|
|
this.unschedule(this._onSlowFxTimeout);
|
||
|
|
this.scheduleOnce(this._onSlowFxTimeout, dur);
|
||
|
|
}
|
||
|
|
|
||
|
|
clearSlowFx(): void {
|
||
|
|
this._slowFxSeq++;
|
||
|
|
this.unschedule(this._onSlowFxTimeout);
|
||
|
|
if (this._slowFx?.isValid) {
|
||
|
|
gg.res.putNode(this._slowFx);
|
||
|
|
}
|
||
|
|
this._slowFx = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
private _onSlowFxTimeout = (): void => {
|
||
|
|
this.clearSlowFx();
|
||
|
|
};
|
||
|
|
|
||
|
|
/** 显示加速特效(prefab/怪_加速),挂在 upEff,由机制结束时回收 */
|
||
|
|
showAccelerateFx(): void {
|
||
|
|
if (!this.node?.isValid || this.IsDead) return;
|
||
|
|
const parent = this.upEff?.isValid ? this.upEff : this.node;
|
||
|
|
this._accelFxLastX = Number.NaN;
|
||
|
|
this._accelFxLastY = Number.NaN;
|
||
|
|
if (this._accelerateFx?.isValid) {
|
||
|
|
this._accelerateFx.active = true;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
let node: Node | null = null;
|
||
|
|
try {
|
||
|
|
node = gg.res.getNode('prefab/', MONSTER_ACCELERATE_FX_PREFAB, GBundle.BattleGameMonster);
|
||
|
|
} catch {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!node?.isValid) return;
|
||
|
|
node.parent = parent;
|
||
|
|
node.setPosition(0, 0, 0);
|
||
|
|
node.active = true;
|
||
|
|
this._accelerateFx = node;
|
||
|
|
const sk = node.getComponentInChildren(sp.Skeleton);
|
||
|
|
if (sk) {
|
||
|
|
sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.SHARED_CACHE);
|
||
|
|
sk.setAnimation(0, MONSTER_ACCELERATE_FX_ANIM, true);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
clearAccelerateFx(): void {
|
||
|
|
if (this._accelerateFx?.isValid) {
|
||
|
|
gg.res.putNode(this._accelerateFx);
|
||
|
|
}
|
||
|
|
this._accelerateFx = null;
|
||
|
|
this._accelFxLastX = Number.NaN;
|
||
|
|
this._accelFxLastY = Number.NaN;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 怪_加速 按本帧位移转向行进方向(预制体默认朝 +X) */
|
||
|
|
private _tickAccelerateFxAngle(): void {
|
||
|
|
const fx = this._accelerateFx;
|
||
|
|
if (!fx?.isValid || !fx.active || !this.node?.isValid) return;
|
||
|
|
const wp = this.node.worldPosition;
|
||
|
|
const x = wp.x;
|
||
|
|
const y = wp.y;
|
||
|
|
if (!Number.isFinite(this._accelFxLastX) || !Number.isFinite(this._accelFxLastY)) {
|
||
|
|
this._accelFxLastX = x;
|
||
|
|
this._accelFxLastY = y;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const dx = x - this._accelFxLastX;
|
||
|
|
const dy = y - this._accelFxLastY;
|
||
|
|
this._accelFxLastX = x;
|
||
|
|
this._accelFxLastY = y;
|
||
|
|
if (dx * dx + dy * dy < 1e-4) return;
|
||
|
|
fx.angle = Math.atan2(dy, dx) * 180 / Math.PI;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 显示点燃特效(prefab/怪_火),挂在 downEff(脚下),按时长自动回收;刷新则重置计时。
|
||
|
|
*/
|
||
|
|
showBurnFx(durationSec: number): void {
|
||
|
|
if (!this.node?.isValid || this.IsDead) return;
|
||
|
|
const parent = this.downEff?.isValid ? this.downEff : this.node;
|
||
|
|
++this._burnFxSeq;
|
||
|
|
|
||
|
|
if (!this._burnFx?.isValid) {
|
||
|
|
const node = gg.res.getNode('prefab/', '怪_火', GBundle.BattleGameMonster);
|
||
|
|
if (!node?.isValid) return;
|
||
|
|
node.parent = parent;
|
||
|
|
node.setPosition(0, 0, 0);
|
||
|
|
node.active = true;
|
||
|
|
this._burnFx = node;
|
||
|
|
const sk = node.getComponentInChildren(sp.Skeleton);
|
||
|
|
if (sk) {
|
||
|
|
sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.SHARED_CACHE);
|
||
|
|
sk.setAnimation(0, 'animation', true);
|
||
|
|
}
|
||
|
|
} else if (this._burnFx.parent !== parent) {
|
||
|
|
this._burnFx.parent = parent;
|
||
|
|
this._burnFx.setPosition(0, 0, 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
const dur = Math.max(0.05, durationSec);
|
||
|
|
this.unschedule(this._onBurnFxTimeout);
|
||
|
|
this.scheduleOnce(this._onBurnFxTimeout, dur);
|
||
|
|
}
|
||
|
|
|
||
|
|
clearBurnFx(): void {
|
||
|
|
this._burnFxSeq++;
|
||
|
|
this.unschedule(this._onBurnFxTimeout);
|
||
|
|
if (this._burnFx?.isValid) {
|
||
|
|
gg.res.putNode(this._burnFx);
|
||
|
|
}
|
||
|
|
this._burnFx = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
private _onBurnFxTimeout = (): void => {
|
||
|
|
this.clearBurnFx();
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 阎王技能命中:已有流血则保持,否则短时播 `怪_流血`。
|
||
|
|
*/
|
||
|
|
playBleedHitFx(): void {
|
||
|
|
if (this._bleedFx?.isValid) return;
|
||
|
|
this.showBleedFx(2);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 显示流血特效(prefab/怪_流血),挂在怪物根节点,按时长自动回收;刷新则重置计时。
|
||
|
|
*/
|
||
|
|
showBleedFx(durationSec: number): void {
|
||
|
|
if (!this.node?.isValid || this.IsDead) return;
|
||
|
|
const parent = this.node;
|
||
|
|
++this._bleedFxSeq;
|
||
|
|
|
||
|
|
if (!this._bleedFx?.isValid) {
|
||
|
|
let node: Node | null = null;
|
||
|
|
try {
|
||
|
|
node = gg.res.getNode('prefab/', '怪_流血', GBundle.BattleGameMonster);
|
||
|
|
} catch {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!node?.isValid) return;
|
||
|
|
node.parent = parent;
|
||
|
|
node.setPosition(0, 0, 0);
|
||
|
|
node.active = true;
|
||
|
|
this._bleedFx = node;
|
||
|
|
for (const sk of node.getComponentsInChildren(sp.Skeleton)) {
|
||
|
|
sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.SHARED_CACHE);
|
||
|
|
const anim = (sk as sp.Skeleton & { defaultAnimation?: string }).defaultAnimation;
|
||
|
|
if (anim) sk.setAnimation(0, anim, true);
|
||
|
|
}
|
||
|
|
} else if (this._bleedFx.parent !== parent) {
|
||
|
|
this._bleedFx.parent = parent;
|
||
|
|
this._bleedFx.setPosition(0, 0, 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
const dur = Math.max(0.05, durationSec);
|
||
|
|
this.unschedule(this._onBleedFxTimeout);
|
||
|
|
this.scheduleOnce(this._onBleedFxTimeout, dur);
|
||
|
|
}
|
||
|
|
|
||
|
|
clearBleedFx(): void {
|
||
|
|
this._bleedFxSeq++;
|
||
|
|
this.unschedule(this._onBleedFxTimeout);
|
||
|
|
if (this._bleedFx?.isValid) {
|
||
|
|
gg.res.putNode(this._bleedFx);
|
||
|
|
}
|
||
|
|
this._bleedFx = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
private _onBleedFxTimeout = (): void => {
|
||
|
|
this.clearBleedFx();
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 显示恶鬼啃食特效(prefab/怪_恶鬼),挂在 upEff,按时长自动回收;刷新则重置计时。
|
||
|
|
*/
|
||
|
|
showGhostBiteFx(durationSec: number): void {
|
||
|
|
if (!this.node?.isValid || this.IsDead) return;
|
||
|
|
const parent = this.upEff?.isValid ? this.upEff : this.node;
|
||
|
|
++this._ghostBiteFxSeq;
|
||
|
|
|
||
|
|
if (!this._ghostBiteFx?.isValid) {
|
||
|
|
let node: Node | null = null;
|
||
|
|
try {
|
||
|
|
node = gg.res.getNode('prefab/', '怪_恶鬼', GBundle.BattleGameMonster);
|
||
|
|
} catch {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!node?.isValid) return;
|
||
|
|
node.parent = parent;
|
||
|
|
node.setPosition(0, 0, 0);
|
||
|
|
node.active = true;
|
||
|
|
this._ghostBiteFx = node;
|
||
|
|
const sk = node.getComponentInChildren(sp.Skeleton);
|
||
|
|
if (sk) {
|
||
|
|
sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.SHARED_CACHE);
|
||
|
|
sk.setAnimation(0, 'animation', true);
|
||
|
|
}
|
||
|
|
} else if (this._ghostBiteFx.parent !== parent) {
|
||
|
|
this._ghostBiteFx.parent = parent;
|
||
|
|
this._ghostBiteFx.setPosition(0, 0, 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
const dur = Math.max(0.05, durationSec);
|
||
|
|
this.unschedule(this._onGhostBiteFxTimeout);
|
||
|
|
this.scheduleOnce(this._onGhostBiteFxTimeout, dur);
|
||
|
|
}
|
||
|
|
|
||
|
|
clearGhostBiteFx(): void {
|
||
|
|
this._ghostBiteFxSeq++;
|
||
|
|
this.unschedule(this._onGhostBiteFxTimeout);
|
||
|
|
if (this._ghostBiteFx?.isValid) {
|
||
|
|
gg.res.putNode(this._ghostBiteFx);
|
||
|
|
}
|
||
|
|
this._ghostBiteFx = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
private _onGhostBiteFxTimeout = (): void => {
|
||
|
|
this.clearGhostBiteFx();
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 显示感电特效(prefab/怪_电),挂在 upEff,按时长自动回收;刷新则重置计时。
|
||
|
|
*/
|
||
|
|
showElecFx(durationSec: number): void {
|
||
|
|
if (!this.node?.isValid || this.IsDead) return;
|
||
|
|
const parent = this.upEff?.isValid ? this.upEff : this.node;
|
||
|
|
++this._elecFxSeq;
|
||
|
|
|
||
|
|
if (!this._elecFx?.isValid) {
|
||
|
|
const node = gg.res.getNode('prefab/', '怪_电', GBundle.BattleGameMonster);
|
||
|
|
if (!node?.isValid) {
|
||
|
|
console.error('[Monster] 感电特效预制体未加载', '怪_电');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
node.parent = parent;
|
||
|
|
node.setPosition(0, 0, 0);
|
||
|
|
node.active = true;
|
||
|
|
this._elecFx = node;
|
||
|
|
const sk = node.getComponentInChildren(sp.Skeleton);
|
||
|
|
if (sk) {
|
||
|
|
sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.SHARED_CACHE);
|
||
|
|
sk.setAnimation(0, 'animation', true);
|
||
|
|
}
|
||
|
|
} else if (this._elecFx.parent !== parent) {
|
||
|
|
this._elecFx.parent = parent;
|
||
|
|
this._elecFx.setPosition(0, 0, 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
const dur = Math.max(0.05, durationSec);
|
||
|
|
this.unschedule(this._onElecFxTimeout);
|
||
|
|
this.scheduleOnce(this._onElecFxTimeout, dur);
|
||
|
|
}
|
||
|
|
|
||
|
|
clearElecFx(): void {
|
||
|
|
this._elecFxSeq++;
|
||
|
|
this.unschedule(this._onElecFxTimeout);
|
||
|
|
if (this._elecFx?.isValid) {
|
||
|
|
gg.res.putNode(this._elecFx);
|
||
|
|
}
|
||
|
|
this._elecFx = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
private _onElecFxTimeout = (): void => {
|
||
|
|
this.clearElecFx();
|
||
|
|
};
|
||
|
|
|
||
|
|
/** 按 Weapon1.atkPos 取受击点世界坐标(1头 2腰 3脚) */
|
||
|
|
getPosByAtkPosIndex(atkPosIndex: number): Vec3 {
|
||
|
|
const ui = this.node.getComponent(UITransform);
|
||
|
|
if (!ui) {
|
||
|
|
return this.node.worldPosition.clone();
|
||
|
|
}
|
||
|
|
const h = Math.max(1, ui.contentSize.height);
|
||
|
|
const bottomY = -h * ui.anchorPoint.y;
|
||
|
|
let localY = bottomY + h * 0.5;
|
||
|
|
if (atkPosIndex === 1) {
|
||
|
|
localY = bottomY + h * 0.82;
|
||
|
|
} else if (atkPosIndex === 2) {
|
||
|
|
const ratio = this.Data?.type === MonsterType.SmallMonster ? 0.32 : 0.38;
|
||
|
|
localY = bottomY + h * ratio;
|
||
|
|
} else if (atkPosIndex === 3) {
|
||
|
|
localY = bottomY + h * 0.18;
|
||
|
|
}
|
||
|
|
return ui.convertToWorldSpaceAR(v3(0, localY, 0));
|
||
|
|
}
|
||
|
|
|
||
|
|
playWalk(speed = 1) {
|
||
|
|
if (!this.Data || this.Data.type == MonsterType.TreasureBox) return;
|
||
|
|
this.playSpine('走路', null, speed);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 空投飞禽降落:播 Spine「降落」(隼 / 鹰) */
|
||
|
|
playParatroopLand(): void {
|
||
|
|
if (!this.Data || this.Data.type === MonsterType.TreasureBox) return;
|
||
|
|
if (isParatroopBirdModel(this._resolveSkinPrefabName())) {
|
||
|
|
this.playSpine(PARATROOP_LAND_ANIM, null, 1, false);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this.playSpine('1降落', null, 1, false);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 机制施法等:对外播放 Spine(如白骨精「攻击」/「待机」) */
|
||
|
|
playSpineAnim(name: string, loop = true, onComplete?: () => void): void {
|
||
|
|
this.playSpine(name, onComplete ?? null, 1, loop);
|
||
|
|
}
|
||
|
|
|
||
|
|
private playSpine(name: string, callback: Function = null, timeScale = 1, loop = true) {
|
||
|
|
if (!this.node || !this.anim) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (this.anim.animation == '死亡') return;
|
||
|
|
this._animBaseTimeScale = timeScale;
|
||
|
|
const animName = this._resolveSpineAnimName(name);
|
||
|
|
BattleSpinePlayback.play(this.anim, animName, {
|
||
|
|
loop,
|
||
|
|
timeScale,
|
||
|
|
optional: animName === '待机',
|
||
|
|
onComplete: callback ? () => callback() : undefined,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 动画不存在时回退 defaultAnimation,避免 Spine 报错且模型不显示 */
|
||
|
|
private _resolveSpineAnimName(name: string): string {
|
||
|
|
if (!this.anim) return name;
|
||
|
|
if (this.anim.findAnimation(name)) return name;
|
||
|
|
const fallback = '待机';
|
||
|
|
if (fallback !== name && this.anim.findAnimation(fallback)) {
|
||
|
|
return fallback;
|
||
|
|
}
|
||
|
|
return name;
|
||
|
|
}
|
||
|
|
|
||
|
|
clear() {
|
||
|
|
this.unscheduleAllCallbacks();
|
||
|
|
GEvent.Ins.targetOff(this);
|
||
|
|
this.clearStunFx();
|
||
|
|
this.clearSlowFx();
|
||
|
|
this.clearBurnFx();
|
||
|
|
this.clearBleedFx();
|
||
|
|
this.clearGhostBiteFx();
|
||
|
|
this.clearElecFx();
|
||
|
|
this.clearAccelerateFx();
|
||
|
|
this.clearAddHpEff();
|
||
|
|
this.clearHpCapEff();
|
||
|
|
this.clearReviveFx();
|
||
|
|
if (this.anim?.isValid) {
|
||
|
|
BattleSpinePlayback.clearPendingComplete(this.anim);
|
||
|
|
BattleSpinePlayback.resetTracks(this.anim);
|
||
|
|
}
|
||
|
|
this.anim = null;
|
||
|
|
this.entityId = null;
|
||
|
|
this.Data = null;
|
||
|
|
this.mechanismImmobile = false;
|
||
|
|
this.boold = null;
|
||
|
|
this._recycleDynamicChildren(this.downEff);
|
||
|
|
this._recycleDynamicChildren(this.upEff);
|
||
|
|
this._recycleDynamicChildren(this.bloodPos);
|
||
|
|
this._recycleDynamicChildren(this.skin);
|
||
|
|
this._resetTemplateNodeState();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 回收挂点下所有动态子节点(皮肤/血条等),模板锚点本身保留 */
|
||
|
|
private _recycleDynamicChildren(parent: Node | null): void {
|
||
|
|
if (!parent?.isValid || parent.children.length <= 0) return;
|
||
|
|
for (const child of [...parent.children]) {
|
||
|
|
if (!child?.isValid) continue;
|
||
|
|
if (child['resKey']) {
|
||
|
|
gg.res.putNode(child);
|
||
|
|
} else {
|
||
|
|
child.destroy();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 模板回池前恢复根节点可视/逻辑状态,避免脏 Sprite 在下次 active 时刷新 UV */
|
||
|
|
private _resetTemplateNodeState(): void {
|
||
|
|
this.IsDead = false;
|
||
|
|
this.state = MonsterState.PATHFINDING;
|
||
|
|
this._tagStatus.clear();
|
||
|
|
this._reviveHeld = false;
|
||
|
|
this.useMxMovement = false;
|
||
|
|
this._atkTarget = null;
|
||
|
|
this._lastAttackTime = 0;
|
||
|
|
this.hp = 0;
|
||
|
|
this.maxHp = 0;
|
||
|
|
this.sizeMul = 1;
|
||
|
|
|
||
|
|
const node = this.node;
|
||
|
|
if (!node?.isValid) return;
|
||
|
|
node.setScale(1, 1, 1);
|
||
|
|
node.opacity = 255;
|
||
|
|
node.setPosition(0, 0, 0);
|
||
|
|
}
|
||
|
|
}
|