import { Node, Vec3, sp } from 'cc'; import { HealthComp, MovementComp, TeamComp, isBajiaoBlowRetreating, isYanSoulClaimed, } from 'db://assets/mx/module/battle/ecs/Components'; import { BattleTeamId, type BattleSession } from 'db://assets/mx/module/battle'; import type { EntityId } from 'db://assets/mx/module/ecs'; import { GBundle, GPath } from '../../game/ConfigRes'; import { BattleMapSpec } from '../map/BattleMapSpec'; import type { MapManager } from '../map/legacy/MapManager'; import { BOW_ATK_ARROW_SPEED } from '../combat/BowAtkRuntime'; import { BOW_RAIN_AIM_DEG } from '../view/HeroWeaponMount'; import type { Skill } from '../skill/Skill'; import type { SkillPresentationContext } from '../skill/SkillPresentationTypes'; import { SkillPresentationEnv } from '../skill/SkillPresentationEnv'; import { SkillTransformDriver } from '../skill/SkillTransformDriver'; import { BattleSpinePlayback } from './BattleSpinePlayback'; import type { ISkillPresenter } from './SkillPresenter'; /** 落点相对格心的最大偏移(格边比例) */ const LAND_JITTER = 0.35; /** 抛物线峰至少高出目标的像素 */ const ARC_ABOVE = 56; /** 落地命中半径(像素) */ const HIT_RADIUS = BattleMapSpec.CELL_SIZE * 0.55; /** 最短飞行秒 */ const MIN_FLY = 0.12; /** 直伤类型 */ const DMG_TYPE = 'arrow_rain'; /** battleHero/prefab/skill/万箭齐发落地效果 */ const LAND_FX_PREFAB = '万箭齐发落地效果'; /** 落地 Spine 动画 */ const LAND_FX_ANIM = '技能落地效果'; /** Skill.skill type=2 解析结果 */ export interface ArrowRainParams { /** 发射间隔(秒) */ interval: number; /** 每格箭数 */ perCell: number; /** 伤害系数 */ scale: number; } /** 待发一箭 */ interface RainShot { /** 落点 X */ toX: number; /** 落点 Y */ toY: number; } /** 落地特效 */ interface RainLandFx { /** 特效节点 */ node: Node; /** 剩余秒 */ wait: number; } /** 在飞一箭 */ interface RainFly { /** 箭节点 */ node: Node; /** 落点 X */ toX: number; /** 落点 Y */ toY: number; /** 所属施法 */ run: ArrowRainRun; } /** 一次万箭齐发 */ interface ArrowRainRun { /** 施法者 */ caster: EntityId; /** 表现上下文 */ ctx: SkillPresentationContext; /** Skill 宿主 */ host: Skill; /** 播控序号 */ seq: number; /** 发射间隔 */ interval: number; /** 伤害系数 */ scale: number; /** 待发队列 */ shots: RainShot[]; /** 下一发下标 */ next: number; /** 距下一发剩余秒 */ wait: number; /** 已发未落地 */ flying: number; /** 发射动画倍速 */ fireScale: number; /** 武器/字形释放动画名 */ animation: string; /** 最近一发动画剩余秒 */ fireWait: number; } let _host: ArrowRainHost | null = null; /** 万箭齐发运行时 */ export function getArrowRainHost(): ArrowRainHost { if (!_host) _host = new ArrowRainHost(); return _host; } /** 万箭齐发:向怪物通行格抛物线射当前武器箭 */ export class ArrowRainHost { private readonly _runs: ArrowRainRun[] = []; private readonly _flies: RainFly[] = []; private readonly _landFx: RainLandFx[] = []; private readonly _from = new Vec3(); private readonly _to = new Vec3(); private readonly _ctrl = new Vec3(); /** 落地扫描所属施法 */ private _hitRun: ArrowRainRun | null = null; private _hitX = 0; private _hitY = 0; /** * 解析 `2,间隔,每格数量,伤害万分比`。 * @param raw Skill.skill */ static parse(raw: string): ArrowRainParams | null { const parts = String(raw ?? '').split(',').map((s) => Number(String(s).trim())); if (parts[0] !== 2) return null; const interval = parts[1]; const perCell = Math.floor(parts[2]); const wan = parts[3]; if (!(interval >= 0) || !(perCell > 0) || !(wan > 0)) { console.log('[ArrowRain] skill 参数无效'); return null; } return { interval, perCell, scale: wan / 10000 }; } /** * 开战齐射。 * @param ctx 表现上下文 * @param host Skill 宿主 */ start(ctx: SkillPresentationContext, host: Skill): void { this._abortCaster(ctx.caster); const raw = String(ctx.params.extraSkill ?? ctx.params.skillRaw ?? ''); const params = ArrowRainHost.parse(raw); if (!params) { host.drainLogicPresentation(ctx); return; } const session = SkillPresentationEnv.getBattleSession(); const map = SkillPresentationEnv.getMap(); if (!session || !map) { console.log('[ArrowRain] 无 session/map'); host.drainLogicPresentation(ctx); return; } host.visualChannel.ensureWeapon(); const template = host.visualChannel.weapon.arrowTemplate; if (!template?.isValid) { console.log('[ArrowRain] 当前武器无投掷物', ctx.params.skillId); host.drainLogicPresentation(ctx); return; } const shots: RainShot[] = []; this._fillShots(session, map, ctx.caster, params.perCell, shots); if (shots.length < 1) { console.log('[ArrowRain] 无怪物通行格'); host.drainLogicPresentation(ctx); return; } const seq = host.beginPresentationCast(ctx); if (!(seq > 0)) return; const animation = String(ctx.params.animation ?? '').trim() || '攻击'; const fireScale = this._resolveFireScale(host, animation, params.interval); this._runs.push({ caster: ctx.caster, ctx, host, seq, interval: params.interval, scale: params.scale, shots, next: 0, wait: 0, flying: 0, fireScale, animation, fireWait: 0, }); } /** * 推进发射。 * @param dt 逻辑秒 */ tick(dt: number): void { if (!(dt > 0)) return; for (let i = this._runs.length - 1; i >= 0; i--) { if (!this._step(this._runs[i], dt)) { this._finish(this._runs[i], true); this._runs.splice(i, 1); } } this._tickLandFx(dt); } /** 结束全部齐射 */ abortAll(): void { for (let i = this._runs.length - 1; i >= 0; i--) { this._finish(this._runs[i], false); } this._runs.length = 0; this._dropLandFx(); } /** * 结束该施法者未完成的齐射。 * @param caster 施法者 */ private _abortCaster(caster: EntityId): void { for (let i = this._runs.length - 1; i >= 0; i--) { if (this._runs[i].caster !== caster) continue; this._finish(this._runs[i], false); this._runs.splice(i, 1); } } /** * 单帧发射。返回 false 表示队列与在飞都结束。 * @param run 运行态 * @param dt 逻辑秒 */ private _step(run: ArrowRainRun, dt: number): boolean { if (!run.ctx.isAlive || !run.host.isPresentationCast(run.seq)) { this._dropFlies(run); return false; } run.wait -= dt; run.fireWait -= dt; while (run.next < run.shots.length && run.wait <= 0) { this._launch(run, run.shots[run.next]); run.next++; run.wait += run.interval; } return run.next < run.shots.length || run.flying > 0 || run.fireWait > 0; } /** * 发一箭。 * @param run 运行态 * @param shot 落点 */ private _launch(run: ArrowRainRun, shot: RainShot): void { const template = run.host.visualChannel.weapon.arrowTemplate; const from = template?.isValid ? template.worldPosition : SkillPresentationEnv.getCasterGridCenter(run.caster); if (!from) { console.log('[ArrowRain] 无发射坐标', run.caster); return; } this._from.set(from.x, from.y, from.z); this._to.set(shot.toX, shot.toY, from.z); this._playFireAnim(run); this._fillCtrl(this._from, this._to); const clone = run.host.visualChannel.spawnFlyArrow(this._from); if (!clone) return; const dist = Vec3.distance(this._from, this._to); const duration = Math.max(MIN_FLY, dist / BOW_ATK_ARROW_SPEED); run.flying++; this._flies.push({ node: clone, toX: shot.toX, toY: shot.toY, run }); SkillTransformDriver.animateParabola({ node: clone, duration, fromWorld: this._from, toWorld: this._to, controlWorld: this._ctrl, onComplete: this._onArrived, }); } /** 箭落地:伤害后回收 */ private readonly _onArrived = (node?: Node): void => { const idx = this._flyIndex(node); if (idx < 0) { if (node) this._recycleOrphan(node); return; } const fly = this._flies[idx]; this._flies.splice(idx, 1); fly.run.flying--; const z = fly.node.isValid ? fly.node.worldPosition.z : 0; this._playLandFx(fly.toX, fly.toY, z); if (fly.run.ctx.isAlive) this._hitLand(fly.run, fly.toX, fly.toY); fly.run.host.visualChannel.recycleFlyArrow(fly.node); }; /** * 落地格附近敌对怪受伤。 * @param run 运行态 * @param x 落点 X * @param y 落点 Y */ private _hitLand(run: ArrowRainRun, x: number, y: number): void { const session = SkillPresentationEnv.getBattleSession(); if (!session || !session.world.isAlive(run.caster)) return; this._hitRun = run; this._hitX = x; this._hitY = y; session.world.view(HealthComp).forEach(this._hitOne); this._hitRun = null; } /** * 单体落地判定。 * @param id 实体 * @param hp 生命 */ private readonly _hitOne = (id: EntityId, hp: HealthComp): void => { const run = this._hitRun; const session = SkillPresentationEnv.getBattleSession(); if (!run || !session) 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; const casterTeam = session.world.getComponent(run.caster, TeamComp); if (!BattleTeamId.isHostile(casterTeam, session.world.getComponent(id, TeamComp))) return; const wp = SkillPresentationEnv.getTargetWorldPos(id); if (!wp) return; const dx = wp.x - this._hitX; const dy = wp.y - this._hitY; if (dx * dx + dy * dy > HIT_RADIUS * HIT_RADIUS) return; run.ctx.dealDamage(id, { scale: run.scale, damageType: DMG_TYPE }); }; /** * 收集敌方来路通行格并按轮次排队(每轮每格一箭)。 * 多区域图再限己方 AreaID:只打主区域内的敌方阵营格。 * @param session 会话 * @param map 地图 * @param caster 施法者 * @param perCell 每格箭数 * @param out 队列 */ private _fillShots(session: BattleSession, map: MapManager, caster: EntityId, perCell: number, out: RainShot[]): void { const enemyCamp = this._enemyPathCampArea(session, caster); const ownArea = this._ownPlayAreaId(map, session, caster); const grids = map.getAllCanWalkMapGrids(); const half = BattleMapSpec.CELL_SIZE * LAND_JITTER; for (let copy = 0; copy < perCell; copy++) { for (let i = 0; i < grids.length; i++) { const g = grids[i]; if (ownArea != null && (g.AreaID | 0) !== ownArea) continue; if (enemyCamp != null) { const camp = session.topology.getCampAreaAtCell({ row: g.Row, col: g.Col }); if (camp !== enemyCamp) continue; } const center = SkillPresentationEnv.getGridWorldCenterByRowCol(g.Row, g.Col); if (!center) continue; out.push({ toX: center.x + session.rng.range(-half, half), toY: center.y + session.rng.range(-half, half), }); } } this._shuffleShots(session, out); } /** * 打乱发射顺序,避免沿路径依次落下。 * @param session 会话 * @param shots 待发队列 */ private _shuffleShots(session: BattleSession, shots: RainShot[]): void { for (let i = shots.length - 1; i > 0; i--) { const j = session.rng.int(0, i); const tmp = shots[i]; shots[i] = shots[j]; shots[j] = tmp; } } /** * 发射间隔短于动画时长时加快播放。 * @param host Skill 宿主 * @param animation 释放动画名 * @param interval 发射间隔 */ private _resolveFireScale(host: Skill, animation: string, interval: number): number { const sk = host.visualChannel.weapon.drawAnim ?? host.visualChannel.weapon.skinAnim; const dur = sk ? BattleSpinePlayback.getAnimDuration(sk, animation) : null; const gap = Math.max(0.001, interval); if (dur == null || !(dur > 0)) return 1; return Math.max(1, dur / gap); } /** * 单支出手:弓朝上拉弓,不跟落点转。 * @param run 运行态 */ private _playFireAnim(run: ArrowRainRun): void { const follow = String(run.ctx.params.skillKind ?? '') === 'weapon' || run.host.visualChannel.useWeapon; run.host.visualChannel.playCastAnim({ caster: run.caster, animation: run.animation, followWeapon: follow, skillKind: String(run.ctx.params.skillKind ?? ''), skillCdSec: Number(run.ctx.params.cd) || 0, primaryTarget: null, skipBowArrow: true, animTimeScale: run.fireScale, holdCasting: true, fixedAimDeg: BOW_RAIN_AIM_DEG, onAnimComplete: this._noopAnim, }); const sk = run.host.visualChannel.weapon.drawAnim ?? run.host.visualChannel.weapon.skinAnim; const dur = sk ? BattleSpinePlayback.getAnimDuration(sk, run.animation) : null; run.fireWait = dur != null && dur > 0 ? dur / run.fireScale : 0; } /** * 施法者主区域 AreaID;通行格只有一种 AreaID 时不限制(避免单区域图空射)。 * @param map 地图 * @param session 会话 * @param caster 施法者 */ private _ownPlayAreaId(map: MapManager, session: BattleSession, caster: EntityId): number | null { if (!this._walkHasSplitArea(map)) return null; const team = session.world.getComponent(caster, TeamComp); const owner = team?.ownerId ?? team?.team ?? 0; return owner === 0 ? 1 : 2; } /** * 通行格是否出现两种及以上 AreaID。 * @param map 地图 */ private _walkHasSplitArea(map: MapManager): boolean { const grids = map.getAllCanWalkMapGrids(); let first = 0; for (let i = 0; i < grids.length; i++) { const id = grids[i].AreaID | 0; if (id < 1) continue; if (first === 0) { first = id; continue; } if (id !== first) return true; } return false; } /** * 攻向施法者的敌方出怪路径 campArea;单阵营图不限制。 * @param session 会话 * @param caster 施法者 */ private _enemyPathCampArea(session: BattleSession, caster: EntityId): number | null { if (!session.topology.hasMultipleCampAreas()) return null; const team = session.world.getComponent(caster, TeamComp); const owner = team?.ownerId ?? team?.team ?? 0; const enemyPort = owner === 0 ? 1 : 2; const area = session.topology.getSpawnPortCampArea(enemyPort); if (area != null && area >= 1) return area; console.log('[ArrowRain] 敌方路径 campArea 缺失', enemyPort); return -1; } /** * 控制点:出弓沿 90° 向上,再弯向落点。 * @param from 起点 * @param to 终点 */ private _fillCtrl(from: Readonly, to: Readonly): void { const dist = Vec3.distance(from, to); const ctrlLen = Math.max(ARC_ABOVE, dist * 0.42); const rad = BOW_RAIN_AIM_DEG * Math.PI / 180; this._ctrl.set( from.x + Math.cos(rad) * ctrlLen, from.y + Math.sin(rad) * ctrlLen, from.z, ); } /** * 回收并结束施法锁。 * @param run 运行态 * @param releaseLock 是否 finish */ private _finish(run: ArrowRainRun, releaseLock: boolean): void { this._dropFlies(run); const host = run.host; if (!host?.isValid || !host.visualChannel) { console.log('[ArrowRain] 收招时宿主已销毁', run.seq); if (!releaseLock && run.ctx.isAlive) run.ctx.cancel('aborted'); return; } host.visualChannel.endHoldCast(); if (releaseLock && host.isPresentationCast(run.seq)) { host.finishPresentationCast(run.seq, false); } else if (!releaseLock && run.ctx.isAlive) { run.ctx.cancel('aborted'); } } /** * 丢掉该次施法在飞箭。 * @param run 运行态 */ private _dropFlies(run: ArrowRainRun): void { for (let i = this._flies.length - 1; i >= 0; i--) { if (this._flies[i].run !== run) continue; const node = this._flies[i].node; this._flies.splice(i, 1); SkillTransformDriver.cancelNode(node); const channel = run.host?.isValid ? run.host.visualChannel : null; if (channel) channel.recycleFlyArrow(node); else this._recycleOrphan(node); } run.flying = 0; } /** * 在飞箭下标。 * @param node 箭 */ private _flyIndex(node: Node | undefined): number { if (!node) return -1; for (let i = 0; i < this._flies.length; i++) { if (this._flies[i].node === node) return i; } return -1; } /** * 无归属箭回收。 * @param node 箭 */ private _recycleOrphan(node: Node): void { SkillTransformDriver.cancelNode(node); if (node.isValid) node.destroy(); } /** * 落点播落地预制体。 * @param x 世界 X * @param y 世界 Y * @param z 世界 Z */ private _playLandFx(x: number, y: number, z: number): void { const parent = SkillPresentationEnv.flyLayer ?? SkillPresentationEnv.getMap()?.ensureHeroParent(); if (!parent?.isValid) { console.log('[ArrowRain] 落地特效无父节点'); return; } let node: Node | null = null; try { node = gg.res.getNode(GPath.HeroSkillPrefab(), LAND_FX_PREFAB, GBundle.BattleHero); } catch (e) { console.log('[ArrowRain] 落地特效加载异常', String(e)); return; } if (!node?.isValid) { console.log('[ArrowRain] 落地特效预制体缺失'); return; } if (!parent.active) parent.active = true; parent.addChild(node); node.active = true; node.layer = parent.layer; node.setWorldPosition(x, y, z); const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); if (!sk?.isValid) { console.log('[ArrowRain] 落地特效缺少 Skeleton'); gg.res.putNode(node); return; } const wait = BattleSpinePlayback.resolveLogicalCompleteDelay(sk, LAND_FX_ANIM); if (wait == null) { console.log('[ArrowRain] 落地动画不存在', LAND_FX_ANIM); gg.res.putNode(node); return; } if (!BattleSpinePlayback.play(sk, LAND_FX_ANIM, { loop: false })) { gg.res.putNode(node); return; } this._landFx.push({ node, wait }); } /** * 推进落地特效,到时回池。 * @param dt 逻辑秒 */ private _tickLandFx(dt: number): void { for (let i = this._landFx.length - 1; i >= 0; i--) { const fx = this._landFx[i]; fx.wait -= dt; if (fx.wait > 0 && fx.node.isValid) continue; this._landFx.splice(i, 1); this._recycleLandFx(fx.node); } } /** * 回池落地特效。 * @param node 特效节点 */ private _recycleLandFx(node: Node): void { if (!node.isValid) return; const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); BattleSpinePlayback.clearPendingComplete(sk); BattleSpinePlayback.resetTracks(sk); gg.res.putNode(node); } /** 回收全部落地特效 */ private _dropLandFx(): void { for (let i = 0; i < this._landFx.length; i++) { this._recycleLandFx(this._landFx[i].node); } this._landFx.length = 0; } /** 武器动画结束不解锁施法(等箭射完) */ private readonly _noopAnim = (): void => { /* 锁由 Host 在队列结束后释放 */ }; } /** Skill.skill type=2 表现入口 */ export class ArrowRainPresenter implements ISkillPresenter { /** * 播放万箭齐发。 * @param ctx 表现上下文 * @param host Skill 宿主 */ present(ctx: SkillPresentationContext, host: Skill): void { if (!ctx.isAlive) { ctx.finish(); return; } getArrowRainHost().start(ctx, host); } }