You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
AdLike2048/assets/script/battle/presentation/IceBallAtkPresenter.ts

272 lines
9.8 KiB

import { isValid, Node, UITransform, Vec3, sp } from 'cc';
import type { EntityId } from 'db://assets/mx/module/ecs';
import { TransformComp } from 'db://assets/mx/module/battle/ecs/Components';
import { GBundle, GPath } from '../../game/ConfigRes';
import { logicalWorldToCocos } from '../bridge/CocosBattleMovementBridge';
import type { Skill } from '../skill/Skill';
import { SkillPresentationEnv } from '../skill/SkillPresentationEnv';
import type { SkillPresentationContext } from '../skill/SkillPresentationTypes';
import type { ISkillPresenter } from './SkillPresenter';
import { BattleSpinePlayback } from './BattleSpinePlayback';
import { buildShadowDealContext } from './SkillShadowContext';
import { logicPosOf, pulseHostileCircle } from './ThrownCircleAtkPresenter';
/** 冰球术 Skill.id(Battlebuff 3052) */
export const ICE_BALL_SKILL_ID = 305201;
/** battleHero/prefab/skill/冰球 */
const ICE_BALL_PREFAB = '冰球';
/** 默认动画名 */
const ICE_BALL_ANIM = 'animation';
/** Spine「冰球」附件卸下时刻兜底(爆炸) */
const ICE_BALL_EXPLODE_FALLBACK = 1.1667;
/** 整段动画时长兜底 */
const ICE_BALL_DURATION_FALLBACK = 2;
/** 影子伤害类型 */
const ICE_BALL_DMG = 'ice_ball';
/**
* 是否冰球术 Skill.id。
* @param skillId Skill.id
*/
export function isIceBallSkillId(skillId: number): boolean {
return Math.floor(Number(skillId) || 0) === ICE_BALL_SKILL_ID;
}
/** 一次陨落冰球 */
interface IceBallRun {
/** 施法者 */
caster: EntityId;
/** 影子伤害上下文 */
ctx: SkillPresentationContext;
/** 圆心逻辑 X */
x: number;
/** 圆心逻辑 Y */
y: number;
/** 半径像素 */
radius: number;
/** 伤害倍率 */
scale: number;
/** 距爆炸剩余秒 */
explodeWait: number;
/** 爆炸后距回池剩余秒 */
endWait: number;
/** 是否已结算爆炸伤害 */
exploded: boolean;
/** 冰球节点 */
node: Node | null;
}
let _host: IceBallHost | null = null;
/** 冰球术表现宿主 */
export function getIceBallHost(): IceBallHost {
if (!_host) _host = new IceBallHost();
return _host;
}
/**
* Skill 305201:释放施法锁后在主目标落点生成「冰球」,动画近结束爆炸并圈伤。
*/
export class IceBallAtkPresenter implements ISkillPresenter {
present(ctx: SkillPresentationContext, host: Skill): void {
if (!ctx.isAlive) {
ctx.finish();
return;
}
const seq = host.beginPresentationCast(ctx);
if (!(seq > 0)) return;
host.disarmAnimComplete();
getIceBallHost().start(ctx);
ctx.releasePresentationLock();
host.finishPresentationCast(seq, false);
}
}
/** 冰球陨落:独立计时爆炸圈伤 */
export class IceBallHost {
private readonly _runs: IceBallRun[] = [];
private _activePulse: IceBallRun | null = null;
/**
* 开战:锁定落点并生成冰球。
* @param ctx 表现上下文
*/
start(ctx: SkillPresentationContext): void {
const parts = String(ctx.params.atkPara ?? '').split(',').map(Number);
const radius = parts[0];
const wan = parts[1];
if (!(radius > 0) || !Number.isFinite(wan) || !(wan > 0)) {
console.log('[IceBall] atk_para 无效', ctx.params.skillId, ctx.params.atkPara);
return;
}
const session = SkillPresentationEnv.getBattleSession();
if (!session) {
console.log('[IceBall] 无战斗会话', ctx.params.skillId);
return;
}
const primary = ctx.targets[0] ?? 0;
const lockId = primary > 0 ? primary : ctx.caster;
const center = logicPosOf(session, lockId) ?? logicPosOf(session, ctx.caster);
if (!center) {
console.log('[IceBall] 无落点', ctx.params.skillId);
return;
}
const placed = this._placeBall(lockId, center);
if (!placed) return;
this._runs.push({
caster: ctx.caster,
ctx: buildShadowDealContext(ctx, String(ctx.params.buff1 ?? ''), String(ctx.params.buff2 ?? '')),
x: center.x,
y: center.y,
radius,
scale: wan / 10000,
explodeWait: placed.explodeAt,
endWait: Math.max(0.05, placed.duration - placed.explodeAt),
exploded: false,
node: placed.node,
});
}
/**
* 推进爆炸与回池。
* @param dt 逻辑秒
*/
tick(dt: number): void {
if (!(dt > 0) || this._runs.length <= 0) return;
const session = SkillPresentationEnv.getBattleSession();
for (let i = this._runs.length - 1; i >= 0; i--) {
const run = this._runs[i];
if (!session || !session.world.isAlive(run.caster) || !run.ctx.isAlive) {
this._drop(run);
this._runs.splice(i, 1);
continue;
}
if (!run.exploded) {
run.explodeWait -= dt;
if (run.explodeWait <= 0) {
this._explode(session, run);
run.exploded = true;
}
continue;
}
run.endWait -= dt;
if (run.endWait <= 0) {
this._drop(run);
this._runs.splice(i, 1);
}
}
}
/** 战斗结束清空 */
abortAll(): void {
for (let i = 0; i < this._runs.length; i++) this._drop(this._runs[i]);
this._runs.length = 0;
}
private _explode(session: NonNullable<ReturnType<typeof SkillPresentationEnv.getBattleSession>>, run: IceBallRun): void {
this._activePulse = run;
pulseHostileCircle(session, run.caster, run.x, run.y, run.radius, this._onPulseHit);
this._activePulse = null;
}
private readonly _onPulseHit = (id: EntityId): void => {
const run = this._activePulse;
if (!run) return;
run.ctx.dealDamage(id, { scale: run.scale, damageType: ICE_BALL_DMG });
};
/**
* 在目标落点生成冰球并播默认动画。
* @param lockId 锁定目标
* @param center 逻辑圆心
*/
private _placeBall(lockId: EntityId, center: { x: number; y: number }): { node: Node; explodeAt: number; duration: number } | null {
const flyLayer = SkillPresentationEnv.flyLayer;
if (!flyLayer?.isValid) {
console.log('[IceBall] 无飞行层');
return null;
}
let node: Node | null = null;
try {
node = gg.res.getNode(GPath.HeroSkillPrefab(), ICE_BALL_PREFAB, GBundle.BattleHero);
} catch (e) {
console.log('[IceBall] 预制体加载异常', String(e));
return null;
}
if (!node?.isValid) {
console.log('[IceBall] 预制体未加载', ICE_BALL_PREFAB);
return null;
}
const ut = flyLayer.getComponent(UITransform);
if (!ut) {
gg.res.putNode(node);
return null;
}
const world = this._resolvePlaceWorld(lockId, center);
if (!world) {
console.log('[IceBall] 无放置世界坐标', lockId);
gg.res.putNode(node);
return null;
}
flyLayer.active = true;
node.parent = flyLayer;
node.active = true;
node.layer = flyLayer.layer;
node.setScale(1, 1, 1);
node.setPosition(ut.convertToNodeSpaceAR(world));
node.setSiblingIndex(flyLayer.children.length - 1);
const sk = node.getComponentInChildren(sp.Skeleton);
if (!sk?.isValid) {
console.log('[IceBall] 缺 Spine', ICE_BALL_PREFAB);
gg.res.putNode(node);
return null;
}
const typed = sk as sp.Skeleton & { defaultAnimation?: string };
const anim = String(typed.defaultAnimation || ICE_BALL_ANIM).trim() || ICE_BALL_ANIM;
if (!BattleSpinePlayback.play(sk, anim, { loop: false })) {
console.log('[IceBall] 动画起播失败', anim);
gg.res.putNode(node);
return null;
}
const durationRaw = BattleSpinePlayback.getAnimDuration(sk, anim);
const duration = durationRaw != null && durationRaw > 0 ? durationRaw : ICE_BALL_DURATION_FALLBACK;
const explodeRaw = BattleSpinePlayback.findFirstAttachmentClearTime(sk, anim, '冰球');
let explodeAt = explodeRaw != null && explodeRaw > 0 ? explodeRaw : ICE_BALL_EXPLODE_FALLBACK;
if (explodeAt >= duration) explodeAt = Math.max(0.05, duration * 0.85);
return { node, explodeAt, duration };
}
/**
* 放置世界坐标:优先怪节点,否则逻辑坐标映射。
* @param lockId 锁定目标
* @param center 逻辑圆心
*/
private _resolvePlaceWorld(lockId: EntityId, center: { x: number; y: number }): Vec3 | null {
const fromMonster = SkillPresentationEnv.getTargetWorldPos(lockId);
if (fromMonster) return fromMonster;
const session = SkillPresentationEnv.getBattleSession();
const map = SkillPresentationEnv.getMap();
if (!session || !map) return null;
const tf = session.world.getComponent(lockId, TransformComp);
if (!tf) return null;
return logicalWorldToCocos(session, map, tf.row, tf.col, center.x, center.y);
}
private _drop(run: IceBallRun): void {
const node = run.node;
run.node = null;
if (!node?.isValid) return;
const skins = node.getComponentsInChildren(sp.Skeleton);
for (let i = 0; i < skins.length; i++) {
BattleSpinePlayback.clearPendingComplete(skins[i]);
BattleSpinePlayback.resetTracks(skins[i]);
}
if (node['resKey']) gg.res.putNode(node);
else if (isValid(node)) node.destroy();
const flyLayer = SkillPresentationEnv.flyLayer;
if (flyLayer?.isValid && flyLayer.children.length === 0) flyLayer.active = false;
}
}