import { Node, UITransform, Vec3, sp } from 'cc'; import type { BattleSession, EntityId } from 'db://assets/mx/module/battle'; 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 { applyMonsterFootOffset } from '../map/BattleMapSpec'; import { findNearestCurrentlyWalkableCell } from '../map/MapPathQueries'; import type { Skill } from '../skill/Skill'; import type { SkillPresentationContext } from '../skill/SkillPresentationTypes'; import { getBattleTimeScale } from '../skill/BattlePresentationClock'; import { SkillPresentationEnv } from '../skill/SkillPresentationEnv'; import { setNodeWorldPos } from '../skill/SkillTransformDriver'; import { BATTLE_MONSTER_CCCLASS, type IBattleMonsterView } from '../view/IBattleMonsterView'; import { BattleSpinePlayback } from './BattleSpinePlayback'; import type { ISkillPresenter } from './SkillPresenter'; /** 枪尖骨骼(枪类皮肤 bone3 在杆头) */ const TIP_BONE = 'bone3'; /** 吸入结束(动画 0.4 突刺前) */ const SUCK_UNTIL = 0.28; /** 砸地关键帧 */ const SMASH_AT = 1.0; /** 砸向格子时长 */ const SLAM_DUR = 0.16; /** 落地压扁恢复 */ const SETTLE_SEC = 0.08; /** 动画未到砸地关键帧时的最长等待(逻辑秒) */ const TOSS_MAX = 3; /** battleHero/prefab/skill/挑飞砸地效果 */ const SMASH_PREFAB = '挑飞砸地效果'; /** 砸地 Spine 动画 */ const SMASH_ANIM = '三尖两面刀技能2'; /** 直伤类型 */ const DMG_TYPE = 'spear_toss'; /** 眩晕 buff */ const STUN_BUFF = 'debuff_4'; /** 吸入缓出 */ const SUCK_EASE = 3; /** 砸地加速 */ const SLAM_EASE = 3; /** Skill.skill type=7 解析结果 */ export interface SpearTossParams { /** 圆形半径(像素) */ radius: number; /** 伤害系数 */ scale: number; /** 眩晕秒 */ stunSec: number; } /** 砸地特效 */ interface TossFx { /** 特效节点 */ node: Node; /** 剩余秒 */ wait: number; } /** 一次挑飞 */ interface SpearTossRun { /** 施法者 */ caster: EntityId; /** 被挑飞怪 */ victim: EntityId; /** 表现上下文 */ ctx: SkillPresentationContext; /** Skill 宿主 */ host: Skill; /** 播控序号 */ seq: number; /** 圆形半径 */ radius: number; /** 伤害系数 */ scale: number; /** 眩晕秒 */ stunSec: number; /** 动画累计秒(无 trackTime 时) */ elapsed: number; /** 0 吸入 / 1 跟随枪尖 / 2 砸地 / 3 特效 */ phase: number; /** 吸入起点 X */ fromX: number; /** 吸入起点 Y */ fromY: number; /** 砸地起点 X */ slamFromX: number; /** 砸地起点 Y */ slamFromY: number; /** 落点 X */ landX: number; /** 落点 Y */ landY: number; /** 落点行 */ landRow: number; /** 落点列 */ landCol: number; /** 砸地已过秒(动画时间) */ slamT: number; /** 落地后特效剩余秒 */ fxWait: number; /** 落地等待初值 */ fxMax: number; /** 默认缩放 X */ scaleX: number; /** 默认缩放 Y */ scaleY: number; } let _host: SpearTossHost | null = null; /** 挑飞砸地运行时 */ export function getSpearTossHost(): SpearTossHost { if (!_host) _host = new SpearTossHost(); return _host; } /** 挑飞砸地:吸到枪尖、随动画挑起、砸到最近可行走格 */ export class SpearTossHost { private readonly _runs: SpearTossRun[] = []; private readonly _fx: TossFx[] = []; private readonly _tmp = new Vec3(); private readonly _tip = new Vec3(); private readonly _boneLocal = new Vec3(); private readonly _land = new Vec3(); /** 当前范围扫描所属施法 */ private _hitRun: SpearTossRun | null = null; private _hitX = 0; private _hitY = 0; private _tipWarned = false; /** * 解析 `7,半径,伤害万分比,眩晕秒`。 * @param raw Skill.skill */ static parse(raw: string): SpearTossParams | null { const parts = String(raw ?? '').split(',').map((s) => Number(String(s).trim())); if (parts[0] !== 7) return null; const radius = parts[1]; const wan = parts[2]; const stunSec = parts[3]; if (!(radius > 0) || !(wan > 0) || !(stunSec >= 0)) { console.log('[SpearToss] skill 参数无效'); return null; } return { radius, scale: wan / 10000, stunSec }; } /** * 开战挑飞。 * @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 = SpearTossHost.parse(raw); if (!params) { host.drainLogicPresentation(ctx); return; } const session = SkillPresentationEnv.getBattleSession(); const map = SkillPresentationEnv.getMap(); if (!session || !map) { console.log('[SpearToss] 无 session/map'); host.drainLogicPresentation(ctx); return; } const victim = ctx.targets[0]; if (victim == null) { console.log('[SpearToss] 无目标', ctx.params.skillId); host.drainLogicPresentation(ctx); return; } const node = SkillPresentationEnv.getMonsterNode(victim); if (!node?.isValid) { console.log('[SpearToss] 目标无节点', victim); host.drainLogicPresentation(ctx); return; } const move = session.world.getComponent(victim, MovementComp); if (!move || move.spawnProtected || isYanSoulClaimed(move) || isBajiaoBlowRetreating(move)) { console.log('[SpearToss] 目标不可挑飞', victim); host.drainLogicPresentation(ctx); return; } const animation = String(ctx.params.animation ?? '').trim(); if (!animation) { console.log('[SpearToss] 缺 animation', ctx.params.skillId); host.drainLogicPresentation(ctx); return; } const follow = String(ctx.params.skillKind ?? '') === 'weapon' || host.visualChannel.useWeapon; if (!host.visualChannel.ensureWeapon()) { console.log('[SpearToss] 无武器', ctx.params.skillId); host.drainLogicPresentation(ctx); return; } const seq = host.beginPresentationCast(ctx); if (!(seq > 0)) return; host.visualChannel.playCastAnim({ caster: ctx.caster, animation, followWeapon: follow, skillKind: String(ctx.params.skillKind ?? ''), skillCdSec: Number(ctx.params.cd) || 0, primaryTarget: victim, holdCasting: true, animTimeScale: 1, onAnimComplete: this._noopAnim, }); move.presentLocked = true; SkillPresentationEnv.holdMonsterVisual(victim); this._runs.push({ caster: ctx.caster, victim, ctx, host, seq, radius: params.radius, scale: params.scale, stunSec: params.stunSec, elapsed: 0, phase: 0, fromX: node.worldPosition.x, fromY: node.worldPosition.y, slamFromX: 0, slamFromY: 0, landX: 0, landY: 0, landRow: -1, landCol: -1, slamT: 0, fxWait: 0, fxMax: SETTLE_SEC, scaleX: node.scale.x, scaleY: node.scale.y, }); } /** * 推进挑飞。 * @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._tickFx(dt); } /** 结束全部挑飞 */ abortAll(): void { for (let i = this._runs.length - 1; i >= 0; i--) { this._finish(this._runs[i], false); } this._runs.length = 0; this._dropFx(); } /** * 结束该施法者未完成的挑飞。 * @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); } } /** 武器动画结束占位(由 Host 收招) */ private readonly _noopAnim = (): void => {}; /** * 单帧。返回 false 表示结束。 * @param run 运行态 * @param dt 逻辑秒 */ private _step(run: SpearTossRun, dt: number): boolean { if (!run.ctx.isAlive || !run.host.isPresentationCast(run.seq)) return false; const node = SkillPresentationEnv.getMonsterNode(run.victim); if (!node?.isValid) return false; run.elapsed += this._animDt(run, dt); const track = this._trackTime(run); if (run.elapsed > TOSS_MAX && run.phase < 2) { console.log('[SpearToss] 动画未到砸地关键帧,结束挑飞', track, run.elapsed); return false; } if (run.phase === 0) { this._applySuck(run, node, track); if (track >= SUCK_UNTIL) run.phase = 1; return true; } if (run.phase === 1) { if (!this._stickTip(run, node)) return false; if (track >= SMASH_AT) { if (!this._beginSlam(run, node)) return false; run.phase = 2; } return true; } if (run.phase === 2) { run.slamT += this._animDt(run, dt); this._applySlam(run, node); if (run.slamT >= SLAM_DUR) { this._onLand(run, node); run.phase = 3; } return true; } run.fxWait -= dt; this._applySettle(run, node); return run.fxWait > 0; } /** * 吸入枪尖。 * @param run 运行态 * @param node 怪节点 * @param track 动画时间 */ private _applySuck(run: SpearTossRun, node: Node, track: number): void { if (!this._readTip(run, this._tip)) { this._tip.set(run.fromX, run.fromY, node.worldPosition.z); } const u = this._easeOut(Math.min(1, Math.max(0, track / SUCK_UNTIL)), SUCK_EASE); this._tmp.set( run.fromX + (this._tip.x - run.fromX) * u, run.fromY + (this._tip.y - run.fromY) * u, node.worldPosition.z, ); setNodeWorldPos(node, this._tmp); const lift = 1 + 0.08 * u; node.setScale(run.scaleX / lift, run.scaleY * lift, 1); } /** * 贴枪尖。 * @param run 运行态 * @param node 怪节点 */ private _stickTip(run: SpearTossRun, node: Node): boolean { if (!this._readTip(run, this._tip)) return false; this._tmp.set(this._tip.x, this._tip.y, node.worldPosition.z); setNodeWorldPos(node, this._tmp); return true; } /** * 锁定砸地落点。 * @param run 运行态 * @param node 怪节点 */ private _beginSlam(run: SpearTossRun, node: Node): boolean { run.slamFromX = node.worldPosition.x; run.slamFromY = node.worldPosition.y; run.slamT = 0; const session = SkillPresentationEnv.getBattleSession(); const map = SkillPresentationEnv.getMap(); if (!session || !map) { console.log('[SpearToss] 砸地时无 session/map'); return false; } if (!this._readTip(run, this._tip)) { this._tip.set(run.slamFromX, run.slamFromY, 0); } const grid = map.getMapGridByWorldPos(this._tip); if (!grid) { console.log('[SpearToss] 枪尖不在地图格上', this._tip.x, this._tip.y); return false; } const snap = findNearestCurrentlyWalkableCell(map, session, { row: grid.Row, col: grid.Col }); if (!snap) { console.log('[SpearToss] 枪尖附近无可行走格', grid.Row, grid.Col); return false; } const center = SkillPresentationEnv.getGridWorldCenterByRowCol(snap.Row, snap.Col); if (!center) { console.log('[SpearToss] 落点格无世界坐标', snap.Row, snap.Col); return false; } applyMonsterFootOffset(center, this._land); run.landX = this._land.x; run.landY = this._land.y; run.landRow = snap.Row; run.landCol = snap.Col; return true; } /** * 砸向落点(加速下落)。 * @param run 运行态 * @param node 怪节点 */ private _applySlam(run: SpearTossRun, node: Node): void { const u = this._easeIn(Math.min(1, run.slamT / SLAM_DUR), SLAM_EASE); this._tmp.set( run.slamFromX + (run.landX - run.slamFromX) * u, run.slamFromY + (run.landY - run.slamFromY) * u, node.worldPosition.z, ); setNodeWorldPos(node, this._tmp); const squash = u * u; node.setScale(run.scaleX * (1 + 0.22 * squash), run.scaleY * (1 - 0.28 * squash), 1); } /** * 落地:特效、范围伤、眩晕、同步格子。 * @param run 运行态 * @param node 怪节点 */ private _onLand(run: SpearTossRun, node: Node): void { this._tmp.set(run.landX, run.landY, node.worldPosition.z); setNodeWorldPos(node, this._tmp); node.setScale(run.scaleX * 1.18, run.scaleY * 0.72, 1); this._playSmashFx(run, node.worldPosition.z); this._syncLogic(run); this._hitArea(run); SkillPresentationEnv.getMonsterPresenter()?.sortMonstersByY(); } /** * 落地压扁回正。 * @param run 运行态 * @param node 怪节点 */ private _applySettle(run: SpearTossRun, node: Node): void { const passed = run.fxMax - run.fxWait; const u = Math.min(1, passed / SETTLE_SEC); node.setScale(run.scaleX * (1.18 - 0.18 * u), run.scaleY * (0.72 + 0.28 * u), 1); if (u >= 1) node.setScale(run.scaleX, run.scaleY, 1); } /** * 写入逻辑格并重寻路(仍存活时)。 * @param run 运行态 */ private _syncLogic(run: SpearTossRun): void { const session = SkillPresentationEnv.getBattleSession(); if (!session?.world.isAlive(run.victim)) return; const tf = session.world.getComponent(run.victim, TransformComp); const move = session.world.getComponent(run.victim, MovementComp); if (!tf || !move) return; tf.row = run.landRow; tf.col = run.landCol; session.spatial.updateEntity(run.victim, { row: run.landRow, col: run.landCol }); const logical = session.grid.cellToWorld({ row: run.landRow, col: run.landCol }); move.worldX = logical.x; move.worldY = logical.y; move.reachedGoal = false; move.path = []; move.pathIndex = 0; move.hasAttackGoal = false; move.forcePresentSync = true; session.movement.assignPath(run.victim); } /** * 落点圆形范围伤 + 眩晕。 * @param run 运行态 */ private _hitArea(run: SpearTossRun): void { const session = SkillPresentationEnv.getBattleSession(); if (!session || !session.world.isAlive(run.caster)) return; this._hitRun = run; this._hitX = run.landX; this._hitY = run.landY; 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; if (!this._inRadius(id, run.radius)) return; if (run.stunSec > 0) this._stun(session, run.caster, id, run.stunSec); run.ctx.dealDamage(id, { scale: run.scale, damageType: DMG_TYPE }); }; /** * 是否在落点半径内(挑飞目标用节点坐标)。 * @param id 实体 * @param radius 半径 */ private _inRadius(id: EntityId, radius: number): boolean { const node = SkillPresentationEnv.getMonsterNode(id); const wp = node?.isValid ? node.worldPosition : SkillPresentationEnv.getTargetWorldPos(id); if (!wp) return false; const dx = wp.x - this._hitX; const dy = wp.y - this._hitY; return dx * dx + dy * dy <= radius * radius; } /** * 上眩晕。 * @param session 会话 * @param caster 施法者 * @param target 目标 * @param dur 秒 */ private _stun(session: BattleSession, caster: EntityId, target: EntityId, dur: number): void { if (!session.config.getBuff(STUN_BUFF)) { console.log('[SpearToss] 缺 buff', STUN_BUFF); return; } session.buffs.add(target, STUN_BUFF, caster, 1, { duration: dur }); } /** * 播砸地预制体。 * @param run 运行态 * @param z 世界 Z */ private _playSmashFx(run: SpearTossRun, z: number): void { const parent = SkillPresentationEnv.flyLayer ?? SkillPresentationEnv.getMap()?.ensureHeroParent(); if (!parent?.isValid) { console.log('[SpearToss] 砸地特效无父节点'); this._setFxWait(run, SETTLE_SEC); return; } let node: Node | null = null; try { node = gg.res.getNode(GPath.HeroSkillPrefab(), SMASH_PREFAB, GBundle.BattleHero); } catch (e) { console.log('[SpearToss] 砸地特效加载异常', String(e)); this._setFxWait(run, SETTLE_SEC); return; } if (!node?.isValid) { console.log('[SpearToss] 砸地特效预制体缺失', SMASH_PREFAB); this._setFxWait(run, SETTLE_SEC); return; } if (!parent.active) parent.active = true; parent.addChild(node); node.active = true; node.layer = parent.layer; node.setWorldPosition(run.landX, run.landY, z); const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); if (!sk?.isValid) { console.log('[SpearToss] 砸地特效缺少 Skeleton'); gg.res.putNode(node); this._setFxWait(run, SETTLE_SEC); return; } const wait = BattleSpinePlayback.resolveLogicalCompleteDelay(sk, SMASH_ANIM); if (wait == null) { console.log('[SpearToss] 砸地动画不存在', SMASH_ANIM); gg.res.putNode(node); this._setFxWait(run, SETTLE_SEC); return; } if (!BattleSpinePlayback.play(sk, SMASH_ANIM, { loop: false })) { gg.res.putNode(node); this._setFxWait(run, SETTLE_SEC); return; } this._fx.push({ node, wait }); this._setFxWait(run, Math.max(SETTLE_SEC, wait)); } /** * 记录落地等待。 * @param run 运行态 * @param wait 秒 */ private _setFxWait(run: SpearTossRun, wait: number): void { run.fxWait = wait; run.fxMax = wait; } /** * 枪尖世界坐标。 * @param run 运行态 * @param out 输出 */ private _readTip(run: SpearTossRun, out: Vec3): boolean { const sk = run.host.visualChannel.weapon.skinAnim; const aim = run.host.visualChannel.weapon.aim; if (sk?.isValid) { const bone = typeof sk.findBone === 'function' ? sk.findBone(TIP_BONE) : null; const ui = sk.node?.getComponent(UITransform); if (bone && ui) { this._boneLocal.set(bone.worldX, bone.worldY, 0); ui.convertToWorldSpaceAR(this._boneLocal, out); return true; } } if (aim?.isValid) { if (!this._tipWarned) { console.log('[SpearToss] 枪尖骨骼缺失,改用朝向节点', TIP_BONE); this._tipWarned = true; } out.set(aim.worldPosition); return true; } console.log('[SpearToss] 无法取得枪尖坐标'); return false; } /** * 当前武器动画时间。 * @param run 运行态 */ private _trackTime(run: SpearTossRun): number { const sk = run.host.visualChannel.weapon.skinAnim; const entry = sk?.isValid && typeof sk.getCurrent === 'function' ? sk.getCurrent(0) : null; const t = entry != null ? Number(entry.trackTime) : NaN; const spine = Number.isFinite(t) && t >= 0 ? t : 0; return spine > run.elapsed ? spine : run.elapsed; } /** * 动画时间增量(跟枪尖关键帧)。 * @param run 运行态 * @param dt 逻辑秒 */ private _animDt(run: SpearTossRun, dt: number): number { const sk = run.host.visualChannel.weapon.skinAnim; const scale = sk?.isValid ? Math.max(0.01, sk.timeScale) : 1; const battle = Math.max(0.01, getBattleTimeScale()); return dt * (scale / battle); } /** * 缓出。 * @param t 0~1 * @param p 次数 */ private _easeOut(t: number, p: number): number { const r = 1 - t; let v = r; for (let i = 1; i < p; i++) v *= r; return 1 - v; } /** * 缓入。 * @param t 0~1 * @param p 次数 */ private _easeIn(t: number, p: number): number { let v = t; for (let i = 1; i < p; i++) v *= t; return v; } /** * 推进砸地特效。 * @param dt 逻辑秒 */ private _tickFx(dt: number): void { for (let i = this._fx.length - 1; i >= 0; i--) { const fx = this._fx[i]; fx.wait -= dt; if (fx.wait > 0) continue; this._recycleFx(fx.node); this._fx.splice(i, 1); } } /** 回收全部砸地特效 */ private _dropFx(): void { for (let i = this._fx.length - 1; i >= 0; i--) { this._recycleFx(this._fx[i].node); } this._fx.length = 0; } /** * 砸地特效回池。 * @param node 特效 */ private _recycleFx(node: Node): void { if (!node?.isValid) return; const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); BattleSpinePlayback.clearPendingComplete(sk); gg.res.putNode(node); } /** * 收招:解锁寻路或回收已死目标。 * @param run 运行态 * @param releaseLock 是否 finish */ private _finish(run: SpearTossRun, releaseLock: boolean): void { const node = SkillPresentationEnv.getMonsterNode(run.victim); if (node?.isValid) node.setScale(run.scaleX, run.scaleY, 1); const session = SkillPresentationEnv.getBattleSession(); if (session?.world.isAlive(run.victim)) { const move = session.world.getComponent(run.victim, MovementComp); if (move) { move.presentLocked = false; move.forcePresentSync = true; } if (run.landRow >= 0) this._syncLogic(run); else session.movement.assignPath(run.victim); const view = node?.getComponent(BATTLE_MONSTER_CCCLASS) as IBattleMonsterView | null; view?.playWalk?.(); } SkillPresentationEnv.releaseMonsterVisual(run.victim); const host = run.host; if (!host?.isValid || !host.visualChannel) { console.log('[SpearToss] 收招时宿主已销毁', 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'); } } } /** Skill.skill type=7 表现入口 */ export class SpearTossPresenter implements ISkillPresenter { /** * 播放挑飞砸地。 * @param ctx 表现上下文 * @param host Skill 宿主 */ present(ctx: SkillPresentationContext, host: Skill): void { if (!ctx.isAlive) { ctx.finish(); return; } getSpearTossHost().start(ctx, host); } }