You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

928 lines
35 KiB

import { _decorator, instantiate, Node, sp, UITransform, Vec3 } from 'cc';
import type { EntityId } from 'db://assets/mx/module/battle';
import { GBundle } from '../../game/ConfigRes';
import type { SkillPresentationContext } from '../skill/SkillPresentationTypes';
import { SkillPresentationEnv } from '../skill/SkillPresentationEnv';
import { SkillTransformDriver, computeAimAngleDeg } from '../skill/SkillTransformDriver';
import {
computeArrowBowAimDeg,
ARROW_BOW_OFFSET_DEG,
} from '../skill/SkillParentAim';
import { BattleAudio } from '../audio/BattleAudio';
import { HeroSkin } from '../view/HeroSkin';
import { BattleSpinePlayback } from '../presentation/BattleSpinePlayback';
import { SkillVisualChannel } from '../presentation/SkillVisualChannel';
import { AttrId } from 'db://assets/mx/module/battle/ecs/Components';
import { BOW_ATK_ARROW_SPEED, SCATTER_RANGE_MUL, getBowAtkHost } from '../combat/BowAtkRuntime';
import { getBattleBuffDigitStatHost } from '../combat/BattleBuffDigitStatHost';
import { HeroActor } from './HeroActor';
const { ccclass } = _decorator;
const NODE_GLYPH = '动画';
/** battleHero/prefab/skill/英雄弓-攻击 */
const ATK_PREFAB = '英雄弓-攻击';
const ATK_PREFAB_PATH = 'prefab/skill/';
const NODE_DRAW = '拉弓动画';
const NODE_PROJECTILE = '投掷物';
const SPINE_TEXT_IDLE = '待机';
/** 攻效拉弓(英雄弓-攻击 / 拉弓动画) */
const SPINE_SKILL_ATTACK2 = '攻击2';
const PROJECTILE_FLY_SPEED = 1280;
/** 拉弓:从休息角平滑拧到瞄准角的时长(秒) */
const AIM_BLEND_SEC = 0.33;
/** 连击时快速转向 */
const COMBO_AIM_BLEND_SEC = 0.1;
/** 控制点落在拉弓射线上的路程比例;保证出弓切线 = 拉弓角 */
const LAUNCH_CTRL_T = 0.42;
/**
* 攻击2 拉满放箭时刻(秒 @ timeScale=1)。
* 弓/玄3 拉到最满 = 0.3667;槽位「弓/弓箭」0.4 才卸;整段动画 0.667 才结束。
*/
const RELEASE_AT_BASE = 0.3667;
/** 拉弓表现上限(秒),须低于 0.2 */
const BOW_ATTACK_MAX_SEC = 0.19;
/** 攻击2 定格后无连击则回到文字待机 */
const COMBO_WINDOW_SEC = 1;
/** 散射 17 首中被消耗的表现箭保底可视飞行时长(秒):近距命中箭不凭空消失 */
const SCATTER_CONSUME_MIN_FLY_SEC = 0.08;
type BowPhase = 'idle' | 'aiming' | 'engaged' | 'returning';
/**
* 弓:HeroActor 壳;字形「弓」只播待机。
* 攻击隐藏字形,加载「英雄弓-攻击」播攻击2;拉满时克隆「投掷物」飞出,攻效定格无箭末帧。
*/
@ccclass('Gong')
export class Gong extends HeroActor {
textAnim: sp.Skeleton = null;
skillAnim: sp.Skeleton = null;
protected _textNode: Node | null = null;
protected _skillParent: Node | null = null;
/** 拉弓 Spine(英雄弓-攻击/拉弓动画) */
protected _skillNode: Node | null = null;
/** 投掷物模板,仅用于 instantiate */
protected _projectileTemplate: Node | null = null;
private _attackSeq = 0;
/** 攻击2 播放段代数;作废过期的 complete/hold */
private _drawSeq = 0;
private _phase: BowPhase = 'idle';
private _comboTimerActive = false;
/** 连击窗口定时代数;递增即作废未触发的 schedulePresentation 回调 */
private _comboTimerSeq = 0;
private _pendingCtx: SkillPresentationContext | null = null;
private _flyingClones: Node[] = [];
detachPooledFx(): void {
this._destroyFlyingClones();
this._disposeAtkPrefab();
this._textNode = null;
super.detachPooledFx();
}
protected onLoad(): void {
this._heroSkin = this.node.getComponent(HeroSkin);
this._bindNodes();
this.onIdle();
}
protected onDisable(): void {
this.teardownPresentation();
this.endPresentationTeardown();
}
protected onDestroy(): void {
this.teardownLifecycle();
}
onUpdateActive(active: boolean): void {
this._heroSkin = this.node.getComponent(HeroSkin) ?? this._heroSkin;
this._bindNodes();
if (!active) {
// 失活打断表现;禁止在连击中直接 _applyDefaultIdleState(会字形待机叠拉弓角)
if (!this.isPresentationTeardown()) this.abortPresentation();
return;
}
if (!this.isPresentationBusy()) this.onIdle();
}
protected resolveTextNode(): Node | null {
const display = this._heroSkin?.displayNode;
if (!display?.isValid) return null;
return display.getChildByName(NODE_GLYPH) ?? display;
}
protected resolveTextAnim(): sp.Skeleton | null {
return this._heroSkin?.anim ?? null;
}
protected onBindNodesComplete(): void {
this.syncAtkFxSkin(this.skillAnim);
this._setProjectileTemplateVisible(false);
if (this._phase === 'idle') {
if (this._skillParent?.isValid) this._skillParent.active = false;
} else {
// 换皮/重绑后字形节点可能已换新实例,必须按当前相位重刷显隐
this._showAtkHideGlyph();
}
}
protected _bindNodes(): void {
if (!this.node?.isValid) return;
this._heroSkin = this.node.getComponent(HeroSkin) ?? this._heroSkin;
this._textNode = this.resolveTextNode();
this.textAnim = this.resolveTextAnim()
?? this._textNode?.getComponent(sp.Skeleton)
?? null;
this._ensureAtkPrefab();
this._skillNode = this._skillParent?.getChildByName(NODE_DRAW)
?? this._findDescendant(this._skillParent, NODE_DRAW)
?? null;
this._projectileTemplate = this._resolveProjectileTemplate();
this.skillAnim = this._skillNode?.getComponent(sp.Skeleton) ?? null;
if (!this._skillParent?.isValid) {
console.log('[Gong] 攻效预制体未加载', ATK_PREFAB);
} else if (!this._skillNode?.isValid) {
console.log('[Gong] 攻效预制体缺少节点', NODE_DRAW);
} else if (!this._projectileTemplate?.isValid) {
console.log('[Gong] 攻效预制体缺少节点', NODE_PROJECTILE);
}
this.onBindNodesComplete();
}
/** 从 battleHero/prefab/skill 取「英雄弓-攻击」,挂到字形根下(与「动画」同级) */
private _ensureAtkPrefab(): void {
if (!this._skillParent?.isValid) {
const atk = gg.res.getNode(ATK_PREFAB_PATH, ATK_PREFAB, GBundle.BattleHero);
if (!atk?.isValid) {
this._skillParent = null;
return;
}
this._skillParent = atk;
}
this._attachAtkToGlyph();
}
/** 攻效挂在英雄展示根上,勿挂进「动画」以免藏字时连弓一起关 */
private _attachAtkToGlyph(): void {
const atk = this._skillParent;
if (!atk?.isValid) return;
const display = this._heroSkin?.displayNode;
const parent = display?.isValid ? display : this.node;
if (atk.parent !== parent) {
atk.parent = parent;
}
atk.setPosition(0, 0, 0);
}
/** 回池前拆下攻效,避免嵌进 heroTemplate 池 */
private _disposeAtkPrefab(): void {
if (this._skillParent?.isValid) {
gg.res.putNode(this._skillParent);
}
this._skillParent = null;
this._skillNode = null;
this._projectileTemplate = null;
this.skillAnim = null;
}
/** 投掷物在「英雄弓-攻击」或「拉弓动画」下 */
private _resolveProjectileTemplate(): Node | null {
if (!this._skillParent?.isValid) return null;
const direct = this._skillParent.getChildByName(NODE_PROJECTILE);
if (direct?.isValid) return direct;
const underDraw = this._skillNode?.getChildByName(NODE_PROJECTILE)
?? this._findDescendant(this._skillParent, NODE_PROJECTILE);
return underDraw?.isValid ? underDraw : null;
}
/** 深度优先按名查找 */
private _findDescendant(root: Node | null | undefined, name: string): Node | null {
if (!root?.isValid) return null;
const direct = root.getChildByName(name);
if (direct?.isValid) return direct;
const children = root.children;
for (let i = 0; i < children.length; i++) {
const found = this._findDescendant(children[i], name);
if (found) return found;
}
return null;
}
/** 攻击根是否挂在字形「动画」子树内 */
private _skillNestedUnderText(): boolean {
if (!this._textNode?.isValid || !this._skillParent?.isValid) return false;
let p: Node | null = this._skillParent.parent;
while (p?.isValid) {
if (p === this._textNode) return true;
p = p.parent;
}
return false;
}
onIdle(): void {
if (this.isPresentationBusy()) return;
this._applyDefaultIdleState();
}
/**
* 弓攻击全程藏字形;基类伴侣字同步会在 textAnim 上播「攻击→待机」,
* 与拉弓叠影/误显字形待机,这里必须空实现。
*/
playPartnerAttackVisual(_timeScale = 1): void {
}
/** 换皮后:先重绑节点,再按相位重刷(连击中仍只显示拉弓) */
onHeroGlyphSkinRefreshed(): void {
this._bindNodes();
if (this.isPresentationBusy()) this._showAtkHideGlyph();
}
/** 闭环终点:字形显示+待机+角度 0,英雄弓-攻击隐藏+角度 0 */
protected _applyDefaultIdleState(): void {
this._drawSeq++;
this._phase = 'idle';
this._comboTimerActive = false;
this._cancelComboTimer();
this._cancelAimAnimations();
this._setProjectileTemplateVisible(false);
if (this._textNode?.isValid) {
this._textNode.angle = 0;
this._textNode.active = true;
}
if (this.textAnim?.isValid) this.textAnim.enabled = true;
if (this._skillParent?.isValid) {
this._skillParent.angle = 0;
this._skillParent.active = false;
}
if (this.textAnim) this.playSpine(this.textAnim, SPINE_TEXT_IDLE);
this._restoreAtkTimeScale();
this._heroSkin?.playIdleAnim();
}
/** skillId Presenter 入口;普攻形态被覆盖为 12/17 时走散射 */
presentNormAttack(ctx: SkillPresentationContext): void {
if (!this.isValid || !this.node?.isValid) {
ctx.finish();
return;
}
const head = Math.floor(Number(ctx.params.atkTypeHead ?? ctx.params.atkType) || 0);
if (head === 12 || head === 17) {
this.presentScatterAttack(ctx);
return;
}
this._bindNodes();
this._playAttackBow(ctx);
}
/** atk_type 12/17 散射(BowScatterAtkPresenter 委托):用自身「英雄弓-攻击」投掷物扇出 */
presentScatterAttack(ctx: SkillPresentationContext): void {
if (!this.isValid || !this.node?.isValid) {
ctx.finish();
return;
}
const head = Math.floor(Number(ctx.params.atkTypeHead ?? ctx.params.atkType) || 0);
this._bindNodes();
this._playAttackBow(ctx, head === 12 ? 12 : 17);
}
abortPresentation(): void {
this.abortPresentationCast();
this._abortPendingPresentation();
}
isPresentationBusy(): boolean {
return this._phase !== 'idle';
}
/**
* 弓普攻:只打 ctx.targets[0](路径最前),箭飞到该目标落地才结算。
* 藏字形「弓」,显「英雄弓-攻击」插值瞄准后播攻击2;拉满发射投掷物。
* 1 秒内连击再播攻击2;窗内无下一箭则插值回休息角切文字待机。
* @param ctx 表现上下文
* @param shape 0 单箭;12 穿透散射;17 不穿透散射
*/
private _playAttackBow(ctx: SkillPresentationContext, shape = 0) {
if (!ctx.isAlive) {
ctx.finish();
return;
}
const shotTargets = ctx.targets.filter(id => SkillPresentationEnv.isPresentTargetValid(id));
if (shotTargets.length === 0) {
ctx.finish();
return;
}
const flyLayer = SkillPresentationEnv.flyLayer;
if (!flyLayer || !this._projectileTemplate?.isValid) {
ctx.setTargets([shotTargets[0]]);
ctx.hit();
ctx.finish();
return;
}
this._supersedePendingCast(ctx);
const seq = ++this._attackSeq;
this._pendingCtx = ctx;
const aspd = Math.max(0.01, SkillPresentationEnv.getFinalAttackSpeed(ctx.caster));
const drawDur = this.skillAnim?.isValid
? BattleSpinePlayback.getAnimDuration(this.skillAnim, SPINE_SKILL_ATTACK2)
: null;
const fitScale = SkillVisualChannel.fitAnimTimeScale(drawDur, 1 / aspd);
const bowAnimTs = Math.max(fitScale, RELEASE_AT_BASE / BOW_ATTACK_MAX_SEC);
let unlocked = false;
const unlockCast = () => {
if (unlocked) return;
unlocked = true;
if (this._pendingCtx === ctx) this._pendingCtx = null;
if (ctx.isAlive) ctx.finish();
};
let pendingFlights = 0;
let visualDone = false;
const finishVisual = () => {
if (visualDone) return;
visualDone = true;
unlockCast();
};
const pivotWorld = this._skillParent?.isValid
? this._skillParent.worldPosition.clone()
: SkillPresentationEnv.getCasterGridCenter(ctx.caster);
const primaryId = shotTargets[0];
const primaryWorld = SkillPresentationEnv.getTargetWorldPos(primaryId);
if (!pivotWorld || !primaryWorld) {
console.log('[Gong] 普攻缺少施法点或目标点', ctx.caster, primaryId);
unlockCast();
finishVisual();
this._beginReturnToRest();
return;
}
const bowAimDeg = computeArrowBowAimDeg(
pivotWorld,
primaryWorld,
ARROW_BOW_OFFSET_DEG,
) ?? (computeAimAngleDeg(pivotWorld, primaryWorld) ?? 0);
// 连击 = 仍处于拉弓交战态(含攻击2 播放中、已拉满定格)。
// 不可用 _comboTimerActive:拉满后、进 hold 前的窗口若按非连击会 setup 定格并打断攻击2。
const isCombo = this._phase === 'engaged';
this._cancelComboTimer();
if (!isCombo) {
this._cancelAimAnimations();
}
let released = false;
const holdEngagedAfterEmptyCast = () => {
if (seq !== this._attackSeq || !this.isValid || !ctx.isAlive) return;
// 仅攻击2 已起播(engaged)才定格;aiming 阶段走正常瞄准
if (this._phase !== 'engaged') return;
this._enterHold(this._drawSeq);
};
const launchVolley = () => {
if (released || seq !== this._attackSeq) {
if (!released) {
unlockCast();
finishVisual();
}
return;
}
const destId = SkillPresentationEnv.isPresentTargetValid(primaryId) ? primaryId : 0;
const destWorld = destId > 0 ? SkillPresentationEnv.getTargetWorldPos(destId) : null;
if (!ctx.isAlive || destId <= 0 || !destWorld) {
unlockCast();
finishVisual();
holdEngagedAfterEmptyCast();
return;
}
released = true;
BattleAudio.playWeaponAttack(ctx.caster);
this._setProjectileTemplateVisible(false);
unlockCast();
if (shape === 12 || shape === 17) {
// 散射(12 穿透 / 17 不穿透):逻辑弹道+表现箭各自回收,不阻塞下一击
this._launchScatterVolley(ctx, destId, shape);
finishVisual();
return;
}
pendingFlights = 1;
const onOneFlightDone = () => {
pendingFlights--;
if (pendingFlights <= 0) finishVisual();
};
const launchDeg = computeArrowBowAimDeg(
pivotWorld,
destWorld,
ARROW_BOW_OFFSET_DEG,
) ?? (computeAimAngleDeg(pivotWorld, destWorld) ?? bowAimDeg);
const projectileClone = this._cloneProjectile();
if (!projectileClone) {
onOneFlightDone();
return;
}
this._launchProjectile({
ctx,
destId,
projectileNode: projectileClone,
flyLayer,
fromWorld: projectileClone.worldPosition.clone(),
toWorld: destWorld,
launchDeg,
onComplete: onOneFlightDone,
});
};
const beginDrawAndRelease = () => {
if (seq !== this._attackSeq || !this.isValid) return;
this._phase = 'engaged';
this.syncAtkFxSkin(this.skillAnim);
this._showAtkHideGlyph();
this._setProjectileTemplateVisible(false);
const drawSeq = ++this._drawSeq;
if (this.skillAnim) {
this.skillAnim.clearTracks();
this.skillAnim.setToSetupPose();
this.playSpine(
this.skillAnim,
SPINE_SKILL_ATTACK2,
() => this._onDrawAnimComplete(drawSeq),
bowAnimTs,
false,
);
} else {
console.log('[Gong] 攻效缺少拉弓动画', NODE_DRAW);
this._enterHold(drawSeq);
}
const releaseDelay = RELEASE_AT_BASE / bowAnimTs;
this.schedulePresentation(() => {
if (seq !== this._attackSeq) {
if (!released) {
unlockCast();
finishVisual();
}
return;
}
if (released) return;
launchVolley();
}, releaseDelay);
};
this._showAtkHideGlyph();
this._setProjectileTemplateVisible(false);
if (!isCombo) {
this._phase = 'aiming';
this._holdAtkSetupPose();
}
const fromAngle = this._skillParent?.isValid
? this._skillParent.angle
: (this._textNode?.angle ?? 0);
const blendSec = isCombo ? COMBO_AIM_BLEND_SEC : AIM_BLEND_SEC;
this._animateAimPair(fromAngle, bowAimDeg, blendSec, beginDrawAndRelease);
}
/** 攻击2 播完:定格无箭末帧,开连击窗(过期 complete 不得再进 hold) */
private _onDrawAnimComplete(drawSeq: number): void {
if (!this.isValid || this._phase !== 'engaged') return;
if (drawSeq !== this._drawSeq) return;
this._enterHold(drawSeq);
}
/** 攻效定格末帧;1 秒内没有下一箭则回文字待机 */
private _enterHold(drawSeq: number): void {
if (drawSeq !== this._drawSeq) return;
this._phase = 'engaged';
this._comboTimerActive = true;
this._showAtkHideGlyph();
this._setProjectileTemplateVisible(false);
if (this.skillAnim?.isValid) {
BattleSpinePlayback.clearPendingComplete(this.skillAnim);
this.skillAnim.timeScale = 0;
}
this._scheduleComboReturn();
}
/** 藏字形「弓」,显「英雄弓-攻击」(投掷物模板保持隐藏,发射时再克隆) */
private _showAtkHideGlyph(): void {
this._setTextVisible(false);
this._setSkillParentVisible(true);
this._setProjectileTemplateVisible(false);
}
/** 瞄准前攻效停在 setup,避免预制体默认循环播攻击2 */
private _holdAtkSetupPose(): void {
if (!this.skillAnim?.isValid) return;
BattleSpinePlayback.resetTracks(this.skillAnim);
this.skillAnim.setToSetupPose();
this.skillAnim.timeScale = 0;
}
/** 解除攻效定格 */
private _restoreAtkTimeScale(): void {
if (!this.skillAnim?.isValid) return;
BattleSpinePlayback.clearPendingComplete(this.skillAnim);
this.skillAnim.timeScale = 1;
}
private _scheduleComboReturn(): void {
const seq = ++this._comboTimerSeq;
this.schedulePresentation(() => {
if (seq !== this._comboTimerSeq) return;
if (!this.isValid || this.isPresentationTeardown()) return;
this._onComboWindowExpired();
}, COMBO_WINDOW_SEC);
}
/** 收招窗到点:本窗内没有下一箭则回文字待机 */
private _onComboWindowExpired = (): void => {
if (!this.isValid || this.isPresentationTeardown() || this._phase !== 'engaged') return;
this._comboTimerActive = false;
this._beginReturnToRest();
};
/** 攻效定格拧回休息角后切文字待机 */
private _beginReturnToRest(): void {
if (!this.isValid || this._phase === 'idle' || this._phase === 'returning') return;
this._phase = 'returning';
this._comboTimerActive = false;
this._cancelComboTimer();
this._setProjectileTemplateVisible(false);
if (this.skillAnim?.isValid) BattleSpinePlayback.clearPendingComplete(this.skillAnim);
this._animateAimToRest(AIM_BLEND_SEC, () => {
if (!this.isValid) return;
// 回正动画期间若已被新普攻抢占(aiming/engaged),不得再掀字形
if (this._phase !== 'returning') return;
this._applyDefaultIdleState();
});
}
/**
* 攻效转到瞄准角(箭发射角与此一致)。
* @param fromDeg 当前角
* @param toDeg 目标角
* @param duration 秒
* @param onComplete 转到后
*/
private _animateAimPair(fromDeg: number, toDeg: number, duration: number, onComplete: () => void): void {
let pending = 0;
let finished = false;
const finishOne = () => {
pending--;
if (pending <= 0 && !finished) {
finished = true;
onComplete();
}
};
for (const node of [this._skillParent]) {
if (!node?.isValid) continue;
pending++;
SkillTransformDriver.cancelNode(node);
node.angle = fromDeg;
SkillTransformDriver.animate({
node,
duration,
fromAngle: fromDeg,
toAngle: toDeg,
easing: SkillTransformDriver.easeSineInOut,
onComplete: finishOne,
});
}
if (pending === 0) onComplete();
}
/**
* 攻效转回休息角。
* @param duration 秒
* @param onComplete 转到后
*/
private _animateAimToRest(duration: number, onComplete: () => void): void {
let pending = 0;
let finished = false;
const finishOne = () => {
pending--;
if (pending <= 0 && !finished) {
finished = true;
onComplete();
}
};
if (this._skillParent?.isValid) {
pending++;
const fromDeg = this._skillParent.angle;
SkillTransformDriver.cancelNode(this._skillParent);
SkillTransformDriver.animate({
node: this._skillParent,
duration,
fromAngle: fromDeg,
toAngle: 0,
easing: SkillTransformDriver.easeSineInOut,
onComplete: finishOne,
});
}
if (pending === 0) onComplete();
}
private _cancelAimAnimations(): void {
if (this._textNode?.isValid) SkillTransformDriver.cancelNode(this._textNode);
if (this._skillParent?.isValid) SkillTransformDriver.cancelNode(this._skillParent);
}
private _cancelComboTimer(): void {
this._comboTimerSeq++;
}
private _setTextVisible(visible: boolean): void {
if (!this._textNode?.isValid) return;
// 攻击根挂在「动画」下时不可关节点,否则弓一并隐藏
if (this._skillNestedUnderText()) {
this._textNode.active = true;
if (this.textAnim?.isValid) this.textAnim.enabled = visible;
return;
}
this._textNode.active = visible;
if (visible && this.textAnim?.isValid) this.textAnim.enabled = true;
}
private _setSkillParentVisible(visible: boolean): void {
if (this._skillParent?.isValid) {
this._skillParent.active = visible;
}
}
private _setProjectileTemplateVisible(visible: boolean): void {
if (this._projectileTemplate?.isValid) {
this._projectileTemplate.active = visible;
}
}
private _cloneProjectile(): Node | null {
if (!this._projectileTemplate?.isValid) return null;
const clone = instantiate(this._projectileTemplate);
clone.parent = this._projectileTemplate.parent;
clone.setPosition(this._projectileTemplate.position);
clone.setScale(this._projectileTemplate.scale);
clone.angle = this._projectileTemplate.angle;
clone.active = true;
this._flyingClones.push(clone);
return clone;
}
/** 表现箭抛物线飞向锁定目标,落地再结算该目标 */
private _launchProjectile(opts: {
ctx: SkillPresentationContext;
destId: EntityId;
projectileNode: Node;
flyLayer: Node;
fromWorld: Vec3;
toWorld: Vec3;
launchDeg: number;
onComplete: () => void;
}): void {
const { ctx, destId, projectileNode, flyLayer, fromWorld, toWorld, launchDeg, onComplete } = opts;
if (!projectileNode.isValid) {
onComplete();
return;
}
flyLayer.active = true;
const flyUt = flyLayer.getComponent(UITransform);
const worldScale = projectileNode.worldScale.clone();
projectileNode.parent = flyLayer;
projectileNode.setWorldScale(worldScale);
projectileNode.angle = launchDeg;
if (flyUt) {
projectileNode.setPosition(flyUt.convertToNodeSpaceAR(fromWorld));
} else {
projectileNode.setWorldPosition(fromWorld);
}
const dist = Vec3.distance(fromWorld, toWorld);
const duration = Math.max(0.12, dist / PROJECTILE_FLY_SPEED);
const controlWorld = this._buildFlightControl(fromWorld, launchDeg, dist);
SkillTransformDriver.animateParabola({
node: projectileNode,
duration,
fromWorld,
toWorld,
controlWorld,
onComplete: () => this._onFlightComplete(ctx, destId, projectileNode, flyLayer, onComplete),
});
}
private _buildFlightControl(
from: Readonly<Vec3>,
launchDeg: number,
dist: number,
): Vec3 {
const rad = launchDeg * Math.PI / 180;
const ctrlLen = Math.max(40, dist * LAUNCH_CTRL_T);
return new Vec3(
from.x + Math.cos(rad) * ctrlLen,
from.y + Math.sin(rad) * ctrlLen,
from.z,
);
}
/**
* 散射出手(12 穿透 / 17 不穿透):以自身「投掷物」为模板扇出直线箭,
* 飞距=出手时射程×倍率;逻辑伤害由 BowAtkHost 体盒结算。
* 17 首中即消耗并当场回收表现箭;没碰怪飞满全程消失。
* @param ctx 表现上下文
* @param destId 瞄准目标(中线对准)
* @param shape 12 / 17
*/
private _launchScatterVolley(ctx: SkillPresentationContext, destId: EntityId, shape: number): void {
const parts = String(ctx.params.atkPara ?? '').split(',').map(Number);
const flags = getBattleBuffDigitStatHost().getFlags(ctx.caster);
const count = Math.floor(parts[0] || 0) + (flags?.extraArrow ?? 0);
const stepDeg = parts[1];
const wan = parts[2];
const rangePx = SkillPresentationEnv.getCasterAttr(ctx.caster, AttrId.Range);
const template = this._projectileTemplate;
const ut = template?.isValid ? template.getComponent(UITransform) : null;
if (!(count > 0) || !(stepDeg > 0) || !(wan > 0) || !(rangePx > 0) || !template?.isValid || !ut) {
console.log('[Gong] 散射参数或投掷物模板无效', ctx.params.atkPara, rangePx);
return;
}
const targetWorld = SkillPresentationEnv.getTargetWorldPos(destId);
const fromWorld = template.worldPosition.clone();
const aimDeg = targetWorld
? (computeAimAngleDeg(fromWorld, targetWorld) ?? 0)
: (this._skillParent?.angle ?? 0);
const degs: number[] = [];
getBowAtkHost().fillScatterDegs(aimDeg, count, stepDeg, degs);
const ws = template.worldScale;
const halfLen = ut.width * Math.abs(ws.x) * 0.5;
const halfWid = ut.height * Math.abs(ws.y) * 0.5;
const dist = rangePx * SCATTER_RANGE_MUL;
const duration = dist / PROJECTILE_FLY_SPEED;
const flyLayer = SkillPresentationEnv.flyLayer;
const spawnAtMs = Date.now();
for (let i = 0; i < degs.length; i++) {
const deg = degs[i];
const clone = this._cloneProjectile();
if (!clone) break;
// 重挂 flyLayer 会丢世界坐标:先记弓上位置,重挂后按世界坐标摆回
const from = clone.worldPosition.clone();
if (flyLayer?.isValid) {
const wsClone = clone.worldScale.clone();
const flyUt = flyLayer.getComponent(UITransform);
flyLayer.active = true;
clone.parent = flyLayer;
clone.setWorldScale(wsClone);
if (flyUt) clone.setPosition(flyUt.convertToNodeSpaceAR(from));
else clone.setWorldPosition(from);
}
clone.angle = deg;
const rad = deg * Math.PI / 180;
const ux = Math.cos(rad);
const uy = Math.sin(rad);
const toWorld = new Vec3(from.x + ux * dist, from.y + uy * dist, from.z);
this._flyScatterClone(clone, flyLayer, from, toWorld, duration);
getBowAtkHost().spawnRay({
caster: ctx.caster,
abilityId: ctx.abilityId,
x: from.x,
y: from.y,
ux,
uy,
speed: BOW_ATK_ARROW_SPEED,
left: duration,
scale: wan / 10000,
halfLen,
halfWid,
buff1: String(ctx.params.buff1 ?? ''),
buff2: String(ctx.params.buff2 ?? ''),
hitIds: [],
pierce: shape !== 17 || (flags?.pierceAdd ?? 0) > 0,
onConsume: shape === 17 && !(flags?.pierceAdd) ? this._consumeScatterClone.bind(this, clone, flyLayer, spawnAtMs) : undefined,
});
}
}
/** 散射表现箭直线飞行(体盒伤害在逻辑层,表现只跟位置) */
private _flyScatterClone(clone: Node, flyLayer: Node | null, fromWorld: Readonly<Vec3>, toWorld: Readonly<Vec3>, duration: number): void {
const flyUt = flyLayer?.getComponent(UITransform) ?? null;
const fromLocal = flyUt ? flyUt.convertToNodeSpaceAR(fromWorld) : fromWorld.clone();
const toLocal = flyUt ? flyUt.convertToNodeSpaceAR(toWorld) : toWorld.clone();
SkillTransformDriver.animate({
node: clone,
duration,
fromPos: fromLocal,
toPos: toLocal,
easing: SkillTransformDriver.easeLinear,
onComplete: () => this._recycleScatterClone(clone, flyLayer),
});
}
/**
* 17 首中被消耗:出手瞬间命中(近距首中)时箭还没飞出来就回收会像没发射,
* 保底再飞 SCATTER_CONSUME_MIN_FLY_SEC 再回收;已飞够时长则当场回收。
*/
private _consumeScatterClone(clone: Node, flyLayer: Node | null, spawnAtMs: number): void {
const elapsedSec = (Date.now() - spawnAtMs) / 1000;
const waitSec = SCATTER_CONSUME_MIN_FLY_SEC - elapsedSec;
if (waitSec <= 0) {
this._recycleScatterClone(clone, flyLayer);
return;
}
this.schedulePresentation(() => this._recycleScatterClone(clone, flyLayer), waitSec);
}
/** 回收散射表现箭(飞到终点或 17 首中被消耗) */
private _recycleScatterClone(clone: Node, flyLayer: Node | null): void {
if (clone?.isValid) {
SkillTransformDriver.cancelNode(clone);
clone.destroy();
}
const idx = this._flyingClones.indexOf(clone);
if (idx >= 0) this._flyingClones.splice(idx, 1);
this._maybeHideFlyLayer(flyLayer);
}
private _onFlightComplete(ctx: SkillPresentationContext, destId: EntityId, projectileNode: Node, flyLayer: Node, onComplete: () => void): void {
this._settleBowLand(ctx, destId);
if (projectileNode.isValid) {
SkillTransformDriver.cancelNode(projectileNode);
projectileNode.destroy();
}
if (this.isValid) {
const cloneIdx = this._flyingClones.indexOf(projectileNode);
if (cloneIdx >= 0) this._flyingClones.splice(cloneIdx, 1);
}
this._maybeHideFlyLayer(flyLayer);
onComplete();
}
/** 箭落地:只打锁定目标,已死不结算 */
private _settleBowLand(ctx: SkillPresentationContext, destId: EntityId): void {
if (!ctx.isAlive || destId <= 0) return;
if (!SkillPresentationEnv.isPresentTargetValid(destId)) return;
ctx.setTargets([destId]);
ctx.dealDamage(destId);
}
private _maybeHideFlyLayer(flyLayer: Node | null): void {
if (flyLayer?.isValid && flyLayer.children.length === 0) {
flyLayer.active = false;
}
}
private _destroyFlyingClones(): void {
const flyLayer = SkillPresentationEnv.flyLayer;
for (const clone of this._flyingClones) {
if (!clone?.isValid) continue;
SkillTransformDriver.cancelNode(clone);
clone.destroy();
}
this._flyingClones.length = 0;
this._maybeHideFlyLayer(flyLayer);
}
/** 新普攻覆盖未 finish 的旧 cast,避免 deferred 施法锁残留 */
private _supersedePendingCast(nextCtx: SkillPresentationContext): void {
const prev = this._pendingCtx;
if (prev && prev !== nextCtx && prev.isAlive) {
prev.cancel('replaced');
}
this.unscheduleAllCallbacks();
}
private _abortPendingPresentation(): void {
this._attackSeq++;
this._drawSeq++;
this._cancelComboTimer();
this.unscheduleAllCallbacks();
this._comboTimerActive = false;
if (this.textAnim?.isValid) BattleSpinePlayback.clearPendingComplete(this.textAnim);
if (this.skillAnim?.isValid) BattleSpinePlayback.clearPendingComplete(this.skillAnim);
const ctx = this._pendingCtx;
this._pendingCtx = null;
this._cancelAimAnimations();
this._destroyFlyingClones();
if (ctx?.isAlive) ctx.cancel('aborted');
if (!this.isPresentationTeardown() && this.isValid) this._applyDefaultIdleState();
}
}