import { instantiate, Node, sp, UITransform, Vec3 } from 'cc'; import type { EntityId } from 'db://assets/mx/module/ecs'; import { AttrId, BattleTeamId, type BattleSession } from 'db://assets/mx/module/battle'; import { HealthComp, MovementComp, TeamComp, isBajiaoBlowRetreating, isYanSoulClaimed, } from 'db://assets/mx/module/battle/ecs/Components'; import { GBundle, GPath } from '../../game/ConfigRes'; import { SkillPresentationEnv } from '../skill/SkillPresentationEnv'; import { SkillTransformDriver } from '../skill/SkillTransformDriver'; import { SkillPresenterRouter } from './SkillPresenterRouter'; import { buildShadowDealContext } from './SkillShadowContext'; import type { Skill } from '../skill/Skill'; import type { SkillPresentationContext } from '../skill/SkillPresentationTypes'; import type { ISkillPresenter } from './SkillPresenter'; /** 龙影预制体(battleHero/prefab/skill/棍,子节点 Spine 剑气) */ const RING_PREFAB = '棍'; /** 龙影 Spine 兜底动画(预制体未勾 playOnLoad,需显式播放) */ const RING_ANIM = '剑气红'; /** 出生外扩兜底(像素):施法者攻击范围取不到时用 */ const RING_SPAWN_OFFSET_FALLBACK = 200; /** 首中保底可视飞行时长(秒):贴脸命中不凭空消失 */ const RING_MIN_VISIBLE_SEC = 0.08; /** 直伤类型 */ const DMG_TYPE = 'extra_ring'; /** Skill.skill type=10 解析结果 */ export interface RingProjectilesParams { /** 起始角度(度,水平向右起逆时针) */ startDeg: number; /** 数量:均分 360° */ count: number; /** 发射间隔(秒) */ intervalSec: number; /** 飞行速度(像素/秒) */ speed: number; /** 飞行时间(秒),飞距 = 速度 × 时间 */ flyTimeSec: number; /** 伤害系数(万分比) */ scale: number; } /** * 解析 `10,起始角度,数量,间隔时间,飞行速度,飞行时间,伤害万分比`。 * @param raw Skill.skill */ export function parseRingProjectilesParams(raw: string): RingProjectilesParams | null { const parts = String(raw ?? '').split(',').map((s) => Number(String(s).trim())); if (parts[0] !== 10) return null; const startDeg = parts[1]; const count = Math.floor(parts[2]); const intervalSec = parts[3]; const speed = parts[4]; const flyTimeSec = parts[5]; const wan = parts[6]; if (!isFinite(startDeg) || !(count > 0) || !(intervalSec >= 0) || !(speed > 0) || !(flyTimeSec > 0) || !(wan > 0)) { console.log('[ExtraRing] skill 参数无效(起始角,数量,间隔,速度,飞行时间,伤害)', raw); return null; } return { startDeg, count, intervalSec, speed, flyTimeSec, scale: wan / 10000 }; } /** 一次环形龙影发射 */ interface ExtraRingRun { /** 施法者 */ caster: EntityId; /** 表现上下文(dealDamage 用) */ ctx: SkillPresentationContext; /** 发射间隔 */ intervalSec: number; /** 飞行速度 */ speed: number; /** 飞行距离(像素) */ flyDist: number; /** 伤害系数 */ scale: number; /** 各支角度(度) */ degs: number[]; /** 下一发下标 */ next: number; /** 距下一发剩余秒 */ wait: number; /** 出生世界坐标(施法瞬间定格) */ origin: Vec3; /** 出生外扩(像素)= 佩戴单位攻击范围 */ spawnOffset: number; } /** 在飞一支龙影 */ interface ExtraRingFly { /** 龙影节点 */ node: Node; /** 所属 run */ run: ExtraRingRun; /** 命中判定半径(像素,表现体外接圆) */ radius: number; /** 已飞秒 */ age: number; /** 已首中(等保底可视时长后回收) */ consumed: boolean; } let _host: ExtraRingHost | null = null; /** 环形龙影运行时 */ export function getExtraRingHost(): ExtraRingHost { if (!_host) _host = new ExtraRingHost(); return _host; } /** Skill.skill type=10:以自身为圆心环形发射龙影,首中即结算消耗,无怪飞满距离消失 */ export class ExtraRingHost { private readonly _runs: ExtraRingRun[] = []; private readonly _flies: ExtraRingFly[] = []; /** * 开战发射:先播本体表现(skillId/atk_type 派发),再起环形发射。 * @param ctx 表现上下文 * @param host Skill 宿主 */ start(ctx: SkillPresentationContext, host: Skill): void { const raw = String(ctx.params.extraSkill ?? ctx.params.skillRaw ?? ''); const params = parseRingProjectilesParams(raw); if (!params) { host.drainLogicPresentation(ctx); return; } // 额外效果不抢 cast 锁:本体表现(如棍攻效)由原派发路径持有 SkillPresenterRouter.instance.dispatchBase(ctx, host); // 上一轮还在逆时针发射中:不开新轮(本体表现照常), // 避免高频普攻反复取消队列导致转圈永远不完整 if (this._runs.some((r) => r.caster === ctx.caster)) return; // 出生圆心按施法者字的实际位置(组合字视觉中心),非组合宿主退回格中心 const origin = SkillPresentationEnv.getCompositionWorldCenter(ctx.caster)?.clone() ?? SkillPresentationEnv.getCasterGridCenter(ctx.caster)?.clone() ?? new Vec3(); // 出生外扩 = 佩戴单位攻击范围(AttrId.Range),取不到用兜底 const session0 = SkillPresentationEnv.getBattleSession(); const rangePx = session0 ? session0.attributes.getAttr(ctx.caster, AttrId.Range) : 0; const spawnOffset = rangePx > 0 ? rangePx : RING_SPAWN_OFFSET_FALLBACK; const step = 360 / params.count; const degs: number[] = []; for (let i = 0; i < params.count; i++) degs.push(params.startDeg + i * step); this._runs.push({ caster: ctx.caster, ctx: buildShadowDealContext(ctx, String(ctx.params.buff1 ?? ''), String(ctx.params.buff2 ?? '')), intervalSec: params.intervalSec, speed: params.speed, flyDist: params.speed * params.flyTimeSec, scale: params.scale, degs, next: 0, wait: 0, origin, spawnOffset, }); } /** * 推进发射、敌对命中与保底回收。 * @param dt 逻辑秒 */ tick(dt: number): void { if (!(dt > 0)) return; for (let i = this._runs.length - 1; i >= 0; i--) { const run = this._runs[i]; if (!run.ctx.isAlive) { this._dropRun(run); this._runs.splice(i, 1); continue; } run.wait -= dt; while (run.next < run.degs.length && run.wait <= 0) { this._launch(run, run.degs[run.next]); run.next++; run.wait += run.intervalSec; } if (run.next >= run.degs.length) this._runs.splice(i, 1); } for (let i = this._flies.length - 1; i >= 0; i--) { const fly = this._flies[i]; fly.age += dt; if (!fly.consumed) this._hitScan(fly); if (fly.consumed && fly.age >= RING_MIN_VISIBLE_SEC) this._finishFly(fly); } } /** * 敌对命中扫描:只对施法者敌对阵营的有血量单位生效 * (我方发射打怪、AI 法神发射打我方),免疫态与 ArrowRain 一致。 */ private _hitScan(fly: ExtraRingFly): void { const session = SkillPresentationEnv.getBattleSession(); if (!session) return; const run = fly.run; if (!session.world.isAlive(run.caster)) return; const casterTeam = session.world.getComponent(run.caster, TeamComp); const wp = fly.node.worldPosition; const r2 = fly.radius * fly.radius; session.world.view(HealthComp).forEach((id, hp) => { if (fly.consumed) return; if (id === run.caster) return; if (!session.world.isAlive(id) || !(hp.hp > 0)) return; const move = session.world.getComponent(id, MovementComp); if (move?.spawnProtected || isYanSoulClaimed(move) || isBajiaoBlowRetreating(move)) return; if (!BattleTeamId.isHostile(casterTeam, session.world.getComponent(id, TeamComp))) return; const tp = SkillPresentationEnv.getTargetWorldPos(id); if (!tp) return; const dx = tp.x - wp.x; const dy = tp.y - wp.y; if (dx * dx + dy * dy > r2) return; if (run.ctx.dealDamage(id, { scale: run.scale, damageType: DMG_TYPE })) fly.consumed = true; }); } /** 结束全部发射(战斗结束) */ abortAll(): void { for (let i = this._runs.length - 1; i >= 0; i--) this._dropRun(this._runs[i]); this._runs.length = 0; for (let i = this._flies.length - 1; i >= 0; i--) this._finishFly(this._flies[i]); } /** 丢掉未发射队列(在飞的不受影响,飞完自然消失) */ private _dropRun(run: ExtraRingRun): void { run.next = run.degs.length; } /** * 发一支龙影。 * @param run 运行态 * @param deg 发射角度 */ private _launch(run: ExtraRingRun, deg: number): void { if (!run.ctx.isAlive) return; const flyLayer = SkillPresentationEnv.flyLayer; const flyUt = flyLayer?.getComponent(UITransform); if (!flyLayer?.isValid || !flyUt) return; const rad = deg * Math.PI / 180; const ux = Math.cos(rad); const uy = Math.sin(rad); const from = new Vec3( run.origin.x + ux * run.spawnOffset, run.origin.y + uy * run.spawnOffset, run.origin.z, ); const to = new Vec3(from.x + ux * run.flyDist, from.y + uy * run.flyDist, from.z); const node = this._spawnClone(deg); if (!node) return; // 模板无父节点,worldScale 即期望尺寸;挂 flyLayer 后按世界缩放摆回 const worldScale = node.worldScale.clone(); // 命中半径 = 表现体外接圆(对角线一半 × 世界缩放) const ut = node.getComponent(UITransform); const halfDiag = ut ? Math.hypot(ut.width, ut.height) / 2 : 40; const radius = halfDiag * (Math.abs(worldScale.x) || 1); flyLayer.active = true; node.parent = flyLayer; node.setWorldScale(worldScale); node.setPosition(flyUt.convertToNodeSpaceAR(from)); const fly: ExtraRingFly = { node, run, radius, age: 0, consumed: false }; this._flies.push(fly); SkillTransformDriver.animate({ node, duration: Math.max(0.05, run.flyDist / run.speed), fromPos: node.position.clone(), toPos: flyUt.convertToNodeSpaceAR(to), easing: SkillTransformDriver.easeLinear, onComplete: () => this._finishFly(fly), }); } /** 从对象池取模板克隆一支并播 Spine */ private _spawnClone(deg: number): Node | null { let tpl: Node | null = null; try { tpl = gg.res.getNode(GPath.HeroSkillPrefab(), RING_PREFAB, GBundle.BattleHero); } catch (e) { console.log('[ExtraRing] 龙影预制体加载异常', String(e)); return null; } if (!tpl?.isValid) { console.log('[ExtraRing] 龙影预制体缺失', RING_PREFAB); return null; } const clone = instantiate(tpl); gg.res.putNode(tpl); clone.active = true; clone.angle = deg; const skel = clone.getComponentInChildren(sp.Skeleton); if (skel?.isValid) { skel.timeScale = 1; skel.setAnimation(0, String(skel.defaultAnimation || RING_ANIM), true); } return clone; } /** 回收一支:停飞、销毁 */ private _finishFly(fly: ExtraRingFly): void { const idx = this._flies.indexOf(fly); if (idx < 0) return; this._flies.splice(idx, 1); if (fly.node?.isValid) { SkillTransformDriver.cancelNode(fly.node); fly.node.destroy(); } } } /** Skill.skill type=10 表现入口 */ export class RingProjectilesPresenter implements ISkillPresenter { /** * 播放环形龙影。 * @param ctx 表现上下文 * @param host Skill 宿主 */ present(ctx: SkillPresentationContext, host: Skill): void { if (!ctx.isAlive) { ctx.finish(); return; } getExtraRingHost().start(ctx, host); } }