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>, 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; } }