import { director, Director, game, instantiate, Node, sp, tween, Tween, UIOpacity, UITransform, Vec3, } from 'cc'; import type { EntityId } from 'db://assets/mx/module/ecs'; import { BattleMapSpec } from '../map/BattleMapSpec'; import { parseAxePiercePx } from '../view/AxeFlyPresenter'; import { SkillCollisionHit, type SkillCollisionPiercePolicy } from './SkillCollisionHit'; import { SkillPresentationEnv } from './SkillPresentationEnv'; import type { SkillPresentationContext } from './SkillPresentationTypes'; import { requireNonNegativePart, requirePositivePart } from './BaseAtkParamReader'; import { SkillTransformDriver } from './SkillTransformDriver'; const RING_ATTACK_SPEED = 640; const RING_ULT_SPEED = 720; const RING_VISUAL_NAME = '乾坤圈攻击'; const TRAIL_NODE_NAME = '乾坤圈拖尾'; const TRAIL_FADE_IN_SEC = 0.22; const MAP_EDGE_INSET_PX = 48; const BOOMERANG_EDGE_INSET_PX = 16; function _resolveBattleMapWorldBounds(insetPx: number): { minX: number; minY: number; maxX: number; maxY: number; } { const half = BattleMapSpec.CELL_SIZE * 0.5; const inset = Math.max(0, insetPx); const map = SkillPresentationEnv.getMap(); if (map?.gridsInitialized) { const corners: Vec3[] = []; const rows = BattleMapSpec.ROWS; const cols = BattleMapSpec.COLS; for (const [row, col] of [ [0, 0], [0, cols - 1], [rows - 1, 0], [rows - 1, cols - 1], ] as const) { const mg = map.getMapGrid(row, col); if (mg?.node?.isValid) corners.push(mg.node.worldPosition.clone()); } if (corners.length > 0) { let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; for (const p of corners) { minX = Math.min(minX, p.x - half); maxX = Math.max(maxX, p.x + half); minY = Math.min(minY, p.y - half); maxY = Math.max(maxY, p.y + half); } return { minX: minX + inset, minY: minY + inset, maxX: maxX - inset, maxY: maxY - inset, }; } } const mapW = BattleMapSpec.COLS * BattleMapSpec.CELL_SIZE; const mapH = BattleMapSpec.ROWS * BattleMapSpec.CELL_SIZE; return { minX: inset, minY: inset, maxX: mapW - inset, maxY: mapH - inset, }; } function _estimateRingRadiusPx(node: Node): number { const ut = node.getComponent(UITransform) ?? node.getComponentInChildren(UITransform); if (!ut) return 40; const ws = node.worldScale; return Math.max(ut.contentSize.width * Math.abs(ws.x), ut.contentSize.height * Math.abs(ws.y)) * 0.5; } export interface NezhaLineSpecials { extraShots: number; spreadDeg: number; growSizeWan: number; growDmgWan: number; boomerang: boolean; } export interface RingFlyHandle { node: Node; hit: SkillCollisionHit | null; dispose(): void; } export function parseNezhaLineSpecials(specials: readonly string[]): NezhaLineSpecials { const out: NezhaLineSpecials = { extraShots: 0, spreadDeg: 0, growSizeWan: 0, growDmgWan: 0, boomerang: false, }; for (const raw of specials) { const parts = String(raw).split(',').map((s) => Number(s.trim())); const head = parts[0] ?? 0; if (head === 3) { const spreadDeg = requirePositivePart(parts, 1); const extraShotsRaw = requireNonNegativePart(parts, 2); if (spreadDeg == null || extraShotsRaw == null) continue; out.spreadDeg = spreadDeg; out.extraShots = Math.floor(extraShotsRaw); } else if (head === 4) { const growSizeWan = requireNonNegativePart(parts, 1); const growDmgWan = requireNonNegativePart(parts, 2); if (growSizeWan == null || growDmgWan == null) continue; out.growSizeWan = Math.max(out.growSizeWan, growSizeWan); out.growDmgWan = Math.max(out.growDmgWan, growDmgWan); } else if (head === 5) { out.boomerang = true; } } return out; } /** 从施法者已选大招词条解析哪吒直线技能 special(1055 变大 / 1056 回旋,up_type 2) */ export function resolveNezhaLineSpecials(caster: EntityId): NezhaLineSpecials { return parseNezhaLineSpecials(SkillPresentationEnv.getUltSkillSpecials(caster)); } function _battleDeltaSec(): number { const battle = gg.game?.CurentBattle as { TimeScale?: number } | null | undefined; const scale = battle?.TimeScale ?? 1; return (game.deltaTime || 1 / 60) * scale; } export function resolveZhaSkillTemplate(skin: Node | null): Node | null { if (!skin?.isValid) return null; const anim = skin.find('anim') ?? skin; return anim.getChildByName('skillTemplate') ?? anim.getChildByName('skillTamplate') ?? null; } export function resolveRingTemplateOnSkin(skin: Node | null): Node | null { if (!skin?.isValid) return null; const anim = skin.find('anim') ?? skin; return anim.getChildByName('atkTemplate') ?? anim.getChildByName('atkTamplate') ?? resolveZhaSkillTemplate(skin); } export function buildSpreadAngles(mainDeg: number, extraShots: number, spreadDeg: number): number[] { const total = 1 + Math.max(0, extraShots); if (total <= 1) return [mainDeg]; const angles: number[] = []; const half = spreadDeg * 0.5; for (let i = 0; i < total; i++) { const t = total === 1 ? 0 : (i / (total - 1)) - 0.5; angles.push(mainDeg + t * spreadDeg * 2); } if (total === 3 && extraShots === 2) { return [mainDeg - half, mainDeg, mainDeg + half]; } return angles; } function _findTrail(projectile: Node): Node | null { return projectile.getChildByName(TRAIL_NODE_NAME) ?? projectile.find(TRAIL_NODE_NAME); } function _beginTrailFadeIn(projectile: Node): void { const trail = _findTrail(projectile); if (!trail?.isValid) return; trail.active = true; const uo = trail.getComponent(UIOpacity) ?? trail.addComponent(UIOpacity); Tween.stopAllByTarget(uo); uo.opacity = 0; tween(uo) .to(TRAIL_FADE_IN_SEC, { opacity: 255 }, { easing: 'sineOut' }) .start(); } function _playRingSpine(projectile: Node): void { const ring = projectile.getChildByName(RING_VISUAL_NAME) ?? projectile.find(RING_VISUAL_NAME); const anim = ring?.getComponent(sp.Skeleton); if (!anim?.skeletonData) return; const data = anim.skeletonData.getRuntimeData?.(); const flyName = data?.findAnimation?.('飞行') ? '飞行' : (data?.findAnimation?.('攻击') ? '攻击' : null); if (!flyName) return; anim.clearTracks(); anim.setAnimation(0, flyName, true); } function _cloneToFlyLayer( template: Node, flyLayer: Node, flyUt: UITransform, anchorWorld: Readonly, ): Node | null { if (!template?.isValid) return null; const clone = instantiate(template); clone.active = true; const worldScale = template.worldScale.clone(); clone.parent = flyLayer; clone.setWorldScale(worldScale); clone.setPosition(flyUt.convertToNodeSpaceAR(anchorWorld)); return clone; } function _angleDeg(from: Readonly, to: Readonly): number { return Math.atan2(to.y - from.y, to.x - from.x) * 180 / Math.PI; } /** 地图对角线跨度(射线与边界无交点时的兜底飞行距离) */ function _mapDiagonalSpanPx(insetPx: number): number { const bounds = _resolveBattleMapWorldBounds(insetPx); return Math.hypot(bounds.maxX - bounds.minX, bounds.maxY - bounds.minY); } /** 沿角度从起点射线与地图可视矩形边界的交点(乾坤圈须完全留在屏内) */ export function computeRayToMapEdge( startWorld: Readonly, angleDeg: number, insetPx: number, ringRadiusPx = 0, ): Vec3 { const inset = insetPx + Math.max(0, ringRadiusPx); const bounds = _resolveBattleMapWorldBounds(inset); const rad = angleDeg * Math.PI / 180; const dx = Math.cos(rad); const dy = Math.sin(rad); let tEnter = -Infinity; let tExit = Infinity; if (Math.abs(dx) < 1e-6) { if (startWorld.x < bounds.minX || startWorld.x > bounds.maxX) { tEnter = Infinity; } } else { const tx1 = (bounds.minX - startWorld.x) / dx; const tx2 = (bounds.maxX - startWorld.x) / dx; tEnter = Math.max(tEnter, Math.min(tx1, tx2)); tExit = Math.min(tExit, Math.max(tx1, tx2)); } if (Math.abs(dy) < 1e-6) { if (startWorld.y < bounds.minY || startWorld.y > bounds.maxY) { tEnter = Infinity; } } else { const ty1 = (bounds.minY - startWorld.y) / dy; const ty2 = (bounds.maxY - startWorld.y) / dy; tEnter = Math.max(tEnter, Math.min(ty1, ty2)); tExit = Math.min(tExit, Math.max(ty1, ty2)); } let tMin = tEnter; if (!Number.isFinite(tMin) || tMin <= 0 || tExit < tEnter) { // 起点在边界外或射线朝外:退回整图对角线,避免只飞 120px 在怪附近消失 tMin = Math.max(120, _mapDiagonalSpanPx(inset)); } return new Vec3( startWorld.x + dx * tMin, startWorld.y + dy * tMin, startWorld.z, ); } function _bindPierce( node: Node, ctx: SkillPresentationContext, policy: SkillCollisionPiercePolicy, ): SkillCollisionHit | null { if (!node?.isValid || !ctx.isAlive) return null; return new SkillCollisionHit().bind(node, ctx, policy); } function _destroyProjectile(node: Node, hit: SkillCollisionHit | null): void { hit?.dispose(); if (!node?.isValid) return; const trail = _findTrail(node); const trailUo = trail?.getComponent(UIOpacity); if (trailUo) Tween.stopAllByTarget(trailUo); SkillTransformDriver.cancelNode(node); node.destroy(); } function _maybeHideFlyLayer(flyLayer: Node | null): void { if (flyLayer?.isValid && flyLayer.children.length === 0) { flyLayer.active = false; } } interface GrowState { elapsed: number; root: Node; /** 克隆到 flyLayer 后、叠体积前的世界缩放(与 setWorldScale 递增一致) */ baseWorldScale: Vec3; /** 出手瞬间体积倍率(含 base_atk 2.5 等) */ launchSizeMul: number; /** 出手瞬间伤害倍率(含 base_atk 6 等) */ launchDamageScale: number; policy: SkillCollisionPiercePolicy; } function _applyProjectileGrowSize(state: GrowState, totalMul: number): void { if (state.root?.isValid) { const bs = state.baseWorldScale; state.root.setWorldScale( bs.x * totalMul, bs.y * totalMul, bs.z, ); } } interface ActiveVolumeGrow { node: Node; state: GrowState; growSizeWan: number; growDmgWan: number; } const _activeVolumeGrow: ActiveVolumeGrow[] = []; let _volumeGrowHooked = false; function _volumeGrowAfterUpdate(): void { if (_activeVolumeGrow.length === 0) return; const dt = _battleDeltaSec(); for (let i = _activeVolumeGrow.length - 1; i >= 0; i--) { const entry = _activeVolumeGrow[i]; const { node, state, growSizeWan, growDmgWan } = entry; if (!node?.isValid) { _activeVolumeGrow.splice(i, 1); continue; } state.elapsed += dt; const sec = Math.max(0, state.elapsed); // 1055:每秒在「发射时」体积/伤害上叠万分比,不是按 1 倍基数叠 const sizeRate = growSizeWan / 10000; const dmgRate = growDmgWan / 10000; const totalSizeMul = state.launchSizeMul * (1 + sizeRate * sec); const totalDmgScale = state.launchDamageScale * (1 + dmgRate * sec); _applyProjectileGrowSize(state, totalSizeMul); state.policy.scale = totalDmgScale; } } function _ensureVolumeGrowHook(): void { if (_volumeGrowHooked) return; _volumeGrowHooked = true; director.on(Director.EVENT_AFTER_UPDATE, _volumeGrowAfterUpdate); } function _attachVolumeAndGrowing( node: Node, baseVolumeScale: number, growSizeWan: number, growDmgWan: number, policy: GrowState['policy'], baseDamageScale: number, ): () => void { const launchSizeMul = Math.max(0.2, baseVolumeScale); const launchDamageScale = baseDamageScale; const hasGrow = growSizeWan > 0 || growDmgWan > 0; if (launchSizeMul === 1 && !hasGrow) return () => {}; const state: GrowState = { elapsed: 0, root: node, baseWorldScale: node.worldScale.clone(), launchSizeMul, launchDamageScale, policy, }; const entry: ActiveVolumeGrow = { node, state, growSizeWan, growDmgWan }; _ensureVolumeGrowHook(); _activeVolumeGrow.push(entry); _applyProjectileGrowSize(state, launchSizeMul); state.policy.scale = launchDamageScale; return () => { const idx = _activeVolumeGrow.indexOf(entry); if (idx >= 0) _activeVolumeGrow.splice(idx, 1); }; } /** 普攻:全程穿透碰撞伤害(对齐弓/箭,出手后 finish,伤害仅 dealDamage) */ export function launchRingAttackFlight(opts: { ctx: SkillPresentationContext; template: Node; anchorWorld: Readonly; targetWorld: Readonly; piercePx: number; flySpeed?: number; onComplete: () => void; }): RingFlyHandle | null { const { ctx, template, anchorWorld, targetWorld, piercePx, onComplete, } = opts; const flySpeed = opts.flySpeed ?? RING_ATTACK_SPEED; if (!ctx.isAlive) { onComplete(); return null; } const flyLayer = SkillPresentationEnv.flyLayer; const flyUt = flyLayer?.getComponent(UITransform); if (!flyLayer?.isValid || !flyUt) { onComplete(); return null; } flyLayer.active = true; const projectile = _cloneToFlyLayer(template, flyLayer, flyUt, anchorWorld); if (!projectile) { onComplete(); return null; } const flyDeg = _angleDeg(anchorWorld, targetWorld); const rad = flyDeg * Math.PI / 180; const pierceEndWorld = piercePx > 0 ? new Vec3( targetWorld.x + Math.cos(rad) * piercePx, targetWorld.y + Math.sin(rad) * piercePx, targetWorld.z, ) : targetWorld; const flyEndWorld = piercePx > 0 ? pierceEndWorld : targetWorld; const startLocal = projectile.position.clone(); const endLocal = flyUt.convertToNodeSpaceAR(flyEndWorld); projectile.angle = flyDeg; _beginTrailFadeIn(projectile); _playRingSpine(projectile); const atkPolicy = { mode: 'pierce' as const, scale: 1 }; let pathHit = _bindPierce(projectile, ctx, atkPolicy); let disposed = false; const dispose = () => { if (disposed) return; disposed = true; _destroyProjectile(projectile, pathHit); pathHit = null; _maybeHideFlyLayer(flyLayer); }; const flyDist = Math.max(1, Vec3.distance(anchorWorld, flyEndWorld)); const duration = Math.max(0.08, flyDist / flySpeed); let flyCompleted = false; const notifyFlyComplete = () => { if (flyCompleted) return; flyCompleted = true; onComplete(); }; const finishFly = () => { dispose(); notifyFlyComplete(); }; SkillTransformDriver.animate({ node: projectile, duration, fromPos: startLocal, toPos: endLocal, easing: SkillTransformDriver.easeLinear, onComplete: finishFly, }); return { node: projectile, get hit() { return pathHit; }, dispose: finishFly, }; } /** 大招:直线无限碰撞 / 回旋 */ export function launchRingUltFlight(opts: { ctx: SkillPresentationContext; template: Node; anchorWorld: Readonly; aimWorld: Readonly; damageScale: number; volumeScale: number; specials: NezhaLineSpecials; angleDeg?: number; onComplete: () => void; }): RingFlyHandle | null { const { ctx, template, anchorWorld, aimWorld, damageScale, volumeScale, specials, onComplete, } = opts; const angleDeg = opts.angleDeg ?? _angleDeg(anchorWorld, aimWorld); if (!ctx.isAlive) { onComplete(); return null; } const flyLayer = SkillPresentationEnv.flyLayer; const flyUt = flyLayer?.getComponent(UITransform); if (!flyLayer?.isValid || !flyUt) { onComplete(); return null; } flyLayer.active = true; const projectile = _cloneToFlyLayer(template, flyLayer, flyUt, anchorWorld); if (!projectile) { onComplete(); return null; } const startLocal = projectile.position.clone(); projectile.angle = angleDeg; _beginTrailFadeIn(projectile); _playRingSpine(projectile); const policy = { mode: 'pierce' as const, scale: damageScale, damageType: 'nezha_ult', }; const stopGrow = _attachVolumeAndGrowing( projectile, volumeScale, specials.growSizeWan, specials.growDmgWan, policy, damageScale, ); const startWorld = projectile.worldPosition.clone(); const ringRadius = _estimateRingRadiusPx(projectile); const inset = specials.boomerang ? BOOMERANG_EDGE_INSET_PX : MAP_EDGE_INSET_PX; const edgeWorld = computeRayToMapEdge(startWorld, angleDeg, inset, ringRadius); const edgeLocal = flyUt.convertToNodeSpaceAR(edgeWorld); let pathHit = _bindPierce(projectile, ctx, policy); let disposed = false; const dispose = () => { if (disposed) return; disposed = true; stopGrow(); _destroyProjectile(projectile, pathHit); pathHit = null; _maybeHideFlyLayer(flyLayer); }; const outboundDist = Math.max(1, Vec3.distance(startLocal, edgeLocal)); const outboundDur = Math.max(0.08, outboundDist / RING_ULT_SPEED); let flyCompleted = false; const notifyFlyComplete = () => { if (flyCompleted) return; flyCompleted = true; onComplete(); }; const finishAll = () => { dispose(); notifyFlyComplete(); }; const flySegment = ( from: Readonly, to: Readonly, onDone: () => void, segmentAngleDeg?: number, ) => { // 每段重新 bind,清空命中集 → 回旋去/回各可对同怪各伤一次 pathHit?.dispose(); pathHit = _bindPierce(projectile, ctx, policy); projectile.angle = segmentAngleDeg ?? _angleDeg(from, to); const segDist = Math.max(1, Vec3.distance(from, to)); const segDur = Math.max(0.08, segDist / RING_ULT_SPEED); SkillTransformDriver.animate({ node: projectile, duration: segDur, fromPos: from.clone(), toPos: to.clone(), easing: SkillTransformDriver.easeLinear, onComplete: onDone, }); }; if (!specials.boomerang) { flySegment(startLocal, edgeLocal, finishAll, angleDeg); } else { const returnDeg = angleDeg + 180; flySegment(startLocal, edgeLocal, () => { if (disposed || !ctx.isAlive) { finishAll(); return; } flySegment(edgeLocal, startLocal, finishAll, returnDeg); }, angleDeg); } return { node: projectile, get hit() { return pathHit; }, dispose: finishAll, }; } export function parseNezhaUltParams(params: Readonly>): { damageScale: number; volumeScale: number; } | null { const parts = Array.isArray(params.skillParts) ? (params.skillParts as number[]) : String(params.skillRaw ?? '').split(',').map(Number); const skillType = parts[0] ?? 0; if (skillType !== 4 || parts.length < 2) return null; const damageScale = requirePositivePart(parts, 1); if (damageScale == null) return null; const volumeScale = parts.length >= 3 ? requirePositivePart(parts, 2) : 1; if (volumeScale == null) return null; return { damageScale, volumeScale }; } export function parseNezhaAttackPiercePx( ctx: SkillPresentationContext, params: Readonly>, ): number { return parseAxePiercePx(params) + Math.max(0, SkillPresentationEnv.getCasterAttr(ctx.caster, 'para_4')); }