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.
272 lines
9.8 KiB
272 lines
9.8 KiB
|
19 hours ago
|
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;
|
||
|
|
}
|
||
|
|
}
|