import { isValid, Node, UITransform, Vec3, sp } from 'cc'; import type { EntityId } from 'db://assets/mx/module/ecs'; import { BattleTeamId } from 'db://assets/mx/module/battle'; import { HealthComp, MovementComp, TeamComp, TransformComp, isBajiaoBlowRetreating, isYanSoulClaimed, } 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 { SkillTransformDriver } from '../skill/SkillTransformDriver'; import { BattleSpinePlayback } from './BattleSpinePlayback'; import { buildShadowDealContext } from './SkillShadowContext'; const DMG_TYPE = 'thrown_circle'; const FLY_SPEED = 576; /** battleHero/prefab/skill/飞棍 */ const FLY_STICK_PREFAB = '飞棍'; /** 飞棍 Spine 循环动画 */ const FLY_STICK_ANIM = '原地旋转大风车'; /** * 飞棍预制体 UITransform 边长 350:scale=1 时视觉半径 175。 * 落地缩放 = atk_para 半径 / 该值,使圈伤范围与表现对齐。 */ const FLY_STICK_DESIGN_RADIUS = 175; /** * 以落点为圆心扫描当前敌对单位并回调(每段重新索敌,不锁施法瞬间名单)。 * @param session 战斗会话 * @param caster 施法者 * @param x 圆心逻辑 X * @param y 圆心逻辑 Y * @param radius 半径像素 * @param onHit 命中回调 */ export function pulseHostileCircle( session: NonNullable>, caster: EntityId, x: number, y: number, radius: number, onHit: (id: EntityId) => void, ): void { if (!(radius > 0)) return; const casterTeam = session.world.getComponent(caster, TeamComp); const cell = session.grid.worldToCell(x, y); const radiusCells = radius / Math.max(1, session.grid.cellSize); const ids = session.spatial.queryRadius(cell, radiusCells + 1); const r2 = radius * radius; for (let i = 0; i < ids.length; i++) { const id = ids[i]; if (id === caster) continue; if (!session.world.isAlive(id)) continue; const hp = session.world.getComponent(id, HealthComp); if (!hp || !(hp.hp > 0)) continue; const move = session.world.getComponent(id, MovementComp); if (move?.spawnProtected || isYanSoulClaimed(move) || isBajiaoBlowRetreating(move)) continue; if (!BattleTeamId.isHostile(casterTeam, session.world.getComponent(id, TeamComp))) continue; const pos = logicPosOf(session, id); if (!pos) continue; const dx = pos.x - x; const dy = pos.y - y; if (dx * dx + dy * dy > r2) continue; onHit(id); } } /** * 实体逻辑坐标。 * @param session 战斗会话 * @param entity 实体 */ export function logicPosOf( session: NonNullable>, entity: EntityId, ): { x: number; y: number } | null { const move = session.world.getComponent(entity, MovementComp); if (move && (move.worldX !== 0 || move.worldY !== 0)) { return { x: move.worldX, y: move.worldY }; } const tf = session.world.getComponent(entity, TransformComp); if (!tf) return null; return session.grid.cellToWorld({ row: tf.row, col: tf.col }); } /** atk_type=18 独立圈伤任务 */ interface ThrownCircleRun { /** 施法者 */ caster: EntityId; /** 影子伤害上下文 */ ctx: SkillPresentationContext; /** 圆心逻辑 X */ x: number; /** 圆心逻辑 Y */ y: number; /** 半径像素 */ radius: number; /** 伤害倍率 */ scale: number; /** 总段数 */ hitCount: number; /** 段间隔秒 */ intervalSec: number; /** 已结算段数 */ done: number; /** 距下一段剩余秒 */ wait: number; /** 飞出的飞棍节点 */ flyNode: Node | null; } /** 飞出结果 */ interface ThrowFlyResult { /** 飞棍节点 */ node: Node; /** 飞行秒数(落地后才开始段伤) */ duration: number; } let _host: ThrownCircleHost | null = null; /** atk_type=18 丢出独立圈伤宿主 */ export function getThrownCircleHost(): ThrownCircleHost { if (!_host) _host = new ThrownCircleHost(); return _host; } /** * atk_type=18:瞬间释放施法锁后,丢出飞棍到主目标落点,按段数/间隔独立圈伤。 */ export class ThrownCircleHost { private readonly _runs: ThrownCircleRun[] = []; /** * 开战:锁定圆心并启动独立段伤。 * @param ctx 表现上下文 * @param host Skill 宿主 */ start(ctx: SkillPresentationContext, host: Skill): void { const hitCount = Math.max(0, Math.floor(Number(ctx.params.hitCount) || 0)); const intervalSec = Math.max(0, Number(ctx.params.hitIntervalSec) || 0); const radius = Math.max(0, Number(ctx.params.aroundPrimaryRadius) || 0); const wan = Number(ctx.params.damageWan); if (!(hitCount > 0) || !(radius > 0) || !Number.isFinite(wan)) { console.log('[ThrownCircle] atk_para 无效', ctx.params.skillId, ctx.params.atkPara); return; } const session = SkillPresentationEnv.getBattleSession(); if (!session) { console.log('[ThrownCircle] 无战斗会话', ctx.params.skillId); return; } const primary = ctx.targets[0] ?? 0; const lockId = primary > 0 ? primary : ctx.caster; const center = this._logicPos(session, lockId) ?? this._logicPos(session, ctx.caster); if (!center) { console.log('[ThrownCircle] 无落点', ctx.params.skillId); return; } const scale = wan / 10000; const shadow = buildShadowDealContext( ctx, String(ctx.params.buff1 ?? ''), String(ctx.params.buff2 ?? ''), ); const fly = this._throwFlyStick(host, ctx.caster, lockId, center, radius); this._runs.push({ caster: ctx.caster, ctx: shadow, x: center.x, y: center.y, radius, scale, hitCount, intervalSec, done: 0, wait: fly?.duration ?? 0, flyNode: fly?.node ?? null, }); } /** * 推进独立段伤。 * @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; } run.wait -= dt; while (run.done < run.hitCount && run.wait <= 0) { this._pulse(session, run); run.done += 1; run.wait += run.intervalSec; } if (run.done >= run.hitCount) { 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 _pulse(session: NonNullable>, run: ThrownCircleRun): void { this._activePulse = run; pulseHostileCircle(session, run.caster, run.x, run.y, run.radius, this._onPulseHit); this._activePulse = null; } private _activePulse: ThrownCircleRun | null = null; private readonly _onPulseHit = (id: EntityId): void => { const run = this._activePulse; if (!run) return; run.ctx.dealDamage(id, { scale: run.scale, damageType: DMG_TYPE }); }; /** * 实例化飞棍飞向锁定怪落点,缩放对齐圈伤半径。 * @param host Skill 宿主 * @param caster 施法者 * @param lockId 锁定目标实体(主目标优先) * @param center 圈伤逻辑坐标 * @param radius 圈伤半径像素 */ private _throwFlyStick(host: Skill, caster: EntityId, lockId: EntityId, center: { x: number; y: number }, radius: number): ThrowFlyResult | null { const flyLayer = SkillPresentationEnv.flyLayer; if (!flyLayer?.isValid) { console.log('[ThrownCircle] 无飞行层'); return null; } let node: Node | null = null; try { node = gg.res.getNode(GPath.HeroSkillPrefab(), FLY_STICK_PREFAB, GBundle.BattleHero); } catch (e) { console.log('[ThrownCircle] 飞棍加载异常', String(e)); return null; } if (!node?.isValid) { console.log('[ThrownCircle] 飞棍预制体未加载', FLY_STICK_PREFAB); return null; } const ut = flyLayer.getComponent(UITransform); if (!ut) { gg.res.putNode(node); return null; } const startWorld = this._resolveThrowStartWorld(host, caster); if (!startWorld) { console.log('[ThrownCircle] 无飞出起点', caster); gg.res.putNode(node); return null; } const targetWorld = this._resolveThrowTargetWorld(lockId, center, startWorld.z); if (!targetWorld) { console.log('[ThrownCircle] 无飞向落点', lockId); gg.res.putNode(node); return null; } const visualScale = Math.max(0.05, radius / FLY_STICK_DESIGN_RADIUS); flyLayer.active = true; node.parent = flyLayer; node.active = true; node.layer = flyLayer.layer; node.setScale(visualScale, visualScale, 1); const startLocal = ut.convertToNodeSpaceAR(startWorld); const targetLocal = ut.convertToNodeSpaceAR(targetWorld); node.setPosition(startLocal); node.setSiblingIndex(flyLayer.children.length - 1); this._playFlyStickAnim(node); const dist = Vec3.distance(startWorld, targetWorld); const duration = Math.max(0.06, dist / FLY_SPEED); SkillTransformDriver.animate({ node, duration, fromPos: startLocal, toPos: targetLocal, easing: SkillTransformDriver.easeSineInOut, }); return { node, duration }; } /** * 飞出起点:优先武器挂点,否则施法者格心。 * @param host Skill 宿主 * @param caster 施法者 */ private _resolveThrowStartWorld(host: Skill, caster: EntityId): Vec3 | null { host.visualChannel.ensureWeapon(); const weaponRoot = host.visualChannel.weapon?.root; if (weaponRoot?.isValid) return weaponRoot.worldPosition.clone(); const grid = SkillPresentationEnv.getCasterGridCenter(caster); return grid ? new Vec3(grid.x, grid.y, grid.z) : null; } /** * 飞向落点:锁定怪 Cocos 世界坐标(禁止 logicX/-logicY 绝对取反)。 * @param lockId 锁定目标 * @param center 圈伤逻辑坐标(无怪节点时回退) * @param z 与起点同 Z */ private _resolveThrowTargetWorld(lockId: EntityId, center: { x: number; y: number }, z: number): Vec3 | null { const fromMonster = SkillPresentationEnv.getTargetWorldPos(lockId); if (fromMonster) { fromMonster.z = z; 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; const cocos = logicalWorldToCocos(session, map, tf.row, tf.col, center.x, center.y); if (!cocos) return null; cocos.z = z; return cocos; } /** * 播飞棍旋转动画。 * @param node 飞棍根 */ private _playFlyStickAnim(node: Node): void { const sk = node.getChildByName('anim')?.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); if (!sk?.isValid) { console.log('[ThrownCircle] 飞棍缺 Spine', FLY_STICK_PREFAB); return; } sk.enabled = true; sk.paused = false; if (!BattleSpinePlayback.play(sk, FLY_STICK_ANIM, { loop: true })) { console.log('[ThrownCircle] 飞棍无动画', FLY_STICK_ANIM); } } private _drop(run: ThrownCircleRun): void { const node = run.flyNode; run.flyNode = null; if (!node?.isValid) return; SkillTransformDriver.cancelNode(node); const sk = node.getChildByName('anim')?.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); BattleSpinePlayback.clearPendingComplete(sk); BattleSpinePlayback.resetTracks(sk); 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; } private _logicPos(session: NonNullable>, entity: EntityId): { x: number; y: number } | null { return logicPosOf(session, entity); } } const GROUND_DMG_TYPE = 'ground_circle'; /** battleHero/prefab/skill/暴风雪 */ const BLIZZARD_PREFAB = '暴风雪'; /** * 暴风雪预制体 UITransform 边长 400:scale=1 时视觉半径 200。 * 落地缩放 = atk_para 半径 / 该值。 */ const BLIZZARD_DESIGN_RADIUS = 200; /** atk_type=20 地面圈伤任务 */ interface GroundCircleRun { /** 施法者 */ caster: EntityId; /** 影子伤害上下文 */ ctx: SkillPresentationContext; /** Skill 宿主(占锁收招) */ host: Skill | null; /** 播控序号 */ seq: number; /** 圆心逻辑 X */ x: number; /** 圆心逻辑 Y */ y: number; /** 半径像素 */ radius: number; /** 伤害倍率 */ scale: number; /** 总段数 */ hitCount: number; /** 段间隔秒 */ intervalSec: number; /** 已结算段数 */ done: number; /** 距下一段剩余秒 */ wait: number; /** 暴风雪节点 */ fxNode: Node | null; } let _groundHost: GroundCircleHost | null = null; /** atk_type=20 目标点地面圈伤宿主 */ export function getGroundCircleHost(): GroundCircleHost { if (!_groundHost) _groundHost = new GroundCircleHost(); return _groundHost; } /** * atk_type=20:以主目标为圆心放置暴风雪(预制体默认循环),按段独立圈伤;占锁至结束。 */ export class GroundCircleHost { private readonly _runs: GroundCircleRun[] = []; private _activePulse: GroundCircleRun | null = null; /** * 开战:锁主目标圆心并放置暴风雪。 * @param ctx 表现上下文 * @param host Skill 宿主 * @param seq 播控序号 */ start(ctx: SkillPresentationContext, host: Skill, seq: number): void { const hitCount = Math.max(0, Math.floor(Number(ctx.params.hitCount) || 0)); const intervalSec = Math.max(0, Number(ctx.params.hitIntervalSec) || 0); const radius = Math.max(0, Number(ctx.params.aroundPrimaryRadius) || 0); const wan = Number(ctx.params.damageWan); if (!(hitCount > 0) || !(radius > 0) || !Number.isFinite(wan)) { console.log('[GroundCircle] atk_para 无效', ctx.params.skillId, ctx.params.atkPara); this._finishCast(host, seq); return; } const session = SkillPresentationEnv.getBattleSession(); if (!session) { console.log('[GroundCircle] 无战斗会话', ctx.params.skillId); this._finishCast(host, seq); return; } const primary = ctx.targets[0] ?? 0; if (!(primary > 0)) { console.log('[GroundCircle] 无索敌目标', ctx.params.skillId); this._finishCast(host, seq); return; } const center = logicPosOf(session, primary); if (!center) { console.log('[GroundCircle] 无落点', ctx.params.skillId, primary); this._finishCast(host, seq); return; } const fxNode = this._placeBlizzard(primary, center, radius); this._runs.push({ caster: ctx.caster, ctx: buildShadowDealContext(ctx, String(ctx.params.buff1 ?? ''), String(ctx.params.buff2 ?? '')), host, seq, x: center.x, y: center.y, radius, scale: wan / 10000, hitCount, intervalSec, done: 0, wait: 0, fxNode, }); } /** * 推进独立段伤。 * @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._endRun(run); this._runs.splice(i, 1); continue; } if (run.host && !run.host.isPresentationCast(run.seq)) { this._drop(run); this._runs.splice(i, 1); continue; } run.wait -= dt; while (run.done < run.hitCount && run.wait <= 0) { this._pulse(session, run); run.done += 1; run.wait += run.intervalSec; } if (run.done >= run.hitCount) { this._endRun(run); this._runs.splice(i, 1); } } } /** * 打断指定施法者的暴风雪。 * @param caster 施法实体 */ abortCaster(caster: EntityId): void { for (let i = this._runs.length - 1; i >= 0; i--) { if (this._runs[i].caster !== caster) continue; this._drop(this._runs[i]); 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; } /** * 收招并结束播控。 * @param host Skill 宿主 * @param seq 播控序号 */ private _finishCast(host: Skill | null, seq: number): void { if (!host?.isValid) return; host.visualChannel.endHoldCast(); if (host.isPresentationCast(seq)) host.finishPresentationCast(seq, false); } private _endRun(run: GroundCircleRun): void { const host = run.host; const seq = run.seq; run.host = null; this._drop(run); this._finishCast(host, seq); } private _pulse(session: NonNullable>, run: GroundCircleRun): 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: GROUND_DMG_TYPE }); }; /** * 在锁定怪位置放置暴风雪,缩放对齐圈伤半径;不强制播动画名,只恢复预制体默认循环。 * @param lockId 锁定目标 * @param center 圈伤逻辑坐标 * @param radius 圈伤半径像素 */ private _placeBlizzard(lockId: EntityId, center: { x: number; y: number }, radius: number): Node | null { const flyLayer = SkillPresentationEnv.flyLayer; if (!flyLayer?.isValid) { console.log('[GroundCircle] 无飞行层'); return null; } let node: Node | null = null; try { node = gg.res.getNode(GPath.HeroSkillPrefab(), BLIZZARD_PREFAB, GBundle.BattleHero); } catch (e) { console.log('[GroundCircle] 暴风雪加载异常', String(e)); return null; } if (!node?.isValid) { console.log('[GroundCircle] 暴风雪预制体未加载', BLIZZARD_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('[GroundCircle] 无放置世界坐标', lockId); gg.res.putNode(node); return null; } const visualScale = Math.max(0.05, radius / BLIZZARD_DESIGN_RADIUS); flyLayer.active = true; node.parent = flyLayer; node.active = true; node.layer = flyLayer.layer; node.setScale(visualScale, visualScale, 1); node.setPosition(ut.convertToNodeSpaceAR(world)); node.setSiblingIndex(flyLayer.children.length - 1); this._resumePrefabDefaultLoop(node); return node; } /** * 放置世界坐标:优先怪节点,否则逻辑坐标映射。 * @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); } /** * 出池后恢复 Spine:只播预制体 defaultAnimation(循环),不硬编码技能动画名。 * @param root 暴风雪根 */ private _resumePrefabDefaultLoop(root: Node): void { const skins = root.getComponentsInChildren(sp.Skeleton); for (let i = 0; i < skins.length; i++) { const sk = skins[i]; if (!sk?.isValid) continue; sk.enabled = true; sk.paused = false; const typed = sk as sp.Skeleton & { defaultAnimation?: string }; const name = String(typed.defaultAnimation || sk.animation || '').trim(); if (!name) continue; if (!BattleSpinePlayback.play(sk, name, { loop: true })) { console.log('[GroundCircle] 暴风雪默认动画失败', name); } } } private _drop(run: GroundCircleRun): void { const node = run.fxNode; run.fxNode = 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; } }