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.
 
 
 
 

1010 lines
38 KiB

import {
_decorator,
Component,
director,
Node,
sp,
tween,
Tween,
UIOpacity,
UITransform,
Vec3,
type ISchedulable,
} from 'cc';
import type { EntityId } from 'db://assets/mx/module/ecs';
import { ensureSpineSkeletonDataRuntime } from 'db://assets/mx/module/lang/spineRuntimePatch';
import {
applyGlyphSpineSkin,
GLYPH_ACTIVE_SKIN_BUCKETS,
GLYPH_SKIN_IDLE,
GLYPH_SKIN_QUALITY,
resolveActiveGlyphSkin,
} from '../view/GlyphSpineSkin';
import type { SkillPresentationContext } from './SkillPresentationTypes';
import { getBattleTimeScale, schedulePresentation as schedulePresentationClock } from './BattlePresentationClock';
import { BattleSpinePlayback } from '../presentation/BattleSpinePlayback';
import { SkillTransformDriver } from './SkillTransformDriver';
import { SkillPresenterRouter } from '../presentation/SkillPresenterRouter';
import { getAllySkillExtraHost } from '../presentation/AllySkillExtraHost';
import { getFaXiangHost } from '../presentation/FaXiangHost';
import { SkillVisualChannel } from '../presentation/SkillVisualChannel';
import { getGroundCircleHost, logicPosOf, pulseHostileCircle } from '../presentation/ThrownCircleAtkPresenter';
import { getStaffSmashHost } from '../presentation/StaffSmashPresenter';
import { SkillPresentationEnv } from './SkillPresentationEnv';
import { SkillShowEffectPlayer } from './SkillShowEffect';
import { AttrId } from 'db://assets/mx/module/battle/ecs/Components';
export type { SkillPresentationContext, SkillAttackFlowSnapshot } from './SkillPresentationTypes';
const { ccclass } = _decorator;
/** 美术资源:品质固定 1,激活皮仅 皮肤字1_3 / 1_4 / 1_5 三档 */
const COMPOSE_TEXT_SKIN_QUALITY = GLYPH_SKIN_QUALITY;
const COMPOSE_TEXT_SKIN_IDLE = GLYPH_SKIN_IDLE;
const COMPOSE_ACTIVE_SKIN_BUCKETS = GLYPH_ACTIVE_SKIN_BUCKETS;
/** atk_type=4 弓箭待落地结算 */
interface BowLandJob {
/** 落地任务 id */
id: number;
/** 施法上下文(finish 后仍可 dealDamage,不可 cancel) */
ctx: SkillPresentationContext;
/** 锁定目标 */
target: EntityId;
}
@ccclass('Skill')
export class Skill extends Component {
/**是否激活战斗状态,默认false */
isActive: boolean = false;
/** 字形/武器视觉通道(表现 Router 用) */
readonly visualChannel = new SkillVisualChannel();
/** Skill.showeffect 预兆 */
private readonly _showEffect = new SkillShowEffectPlayer();
/** 有预兆:命中与额外效果等预兆起播后 0.33s 再施加 */
private _showEffectGate = false;
/** 多段已登记、等预兆命中时刻再起跳 */
private _presMultiHitArmed = false;
/** atk_type=10 开战锁定的单体快照 */
private readonly _presSmashPool: EntityId[] = [];
/** 预兆已施加(或无预兆) */
private _showEffectApplied = true;
/** 英雄动画已结束,仍等预兆施加后再 finish */
private _presAnimWaitForShow = false;
/** 回收/销毁中:不再恢复待机表现(字形已回池) */
protected _presentationTeardown = false;
private _presSeq = 0;
private _presCtx: SkillPresentationContext | null = null;
private _presFinished = false;
/** 由武器/字形动画 complete 收招的序号;0=冲锋等长表现,旧动画不得收招 */
private _presAnimSeq = 0;
/** 本次播控动画已结束(多段 hit 不得早于动画 finish) */
private _presAnimComplete = false;
/** 字形动画播完不收招,等攻效播完再收招(环形刀光等) */
private _presHoldUntilFxComplete = false;
/** atk_type=4 弓:当前施法伤害等箭落地,动画结束不得 drain */
private _presBowLand = false;
/** 弓箭落地任务自增 id */
private _bowLandSeq = 0;
/** 当前施法对应的落地 id;0=无 */
private _presBowLandId = 0;
/** 在飞箭待落地结算(支持震天弓等长飞距与下一击重叠) */
private readonly _bowLandJobs: BowLandJob[] = [];
/** HeroActor 回池或节点销毁前调用 */
beginPresentationTeardown(): void {
this._presentationTeardown = true;
}
protected endPresentationTeardown(): void {
this._presentationTeardown = false;
}
protected isPresentationTeardown(): boolean {
return this._presentationTeardown;
}
/** 回池/禁用时:标记 teardown 并打断表现,避免 onIdle 触回池字形 */
protected teardownPresentation(): void {
this.beginPresentationTeardown();
this._showEffect.release();
this.abortPresentation();
}
/** 节点销毁:打断表现并拆下池化 FX */
protected teardownLifecycle(): void {
this.teardownPresentation();
this.detachPooledFx();
}
/** abort 后恢复待机(teardown 期间跳过) */
protected idleAfterAbortUnlessTeardown(): void {
if (!this.isPresentationTeardown() && this.isValid) this.onIdle();
}
/** 更新激活状态 */
onSetActiveAtk(isActive: boolean) {
this.isActive = isActive;
if (!this._presentationTeardown) {
this.onUpdateActive(isActive);
}
}
/** 更新激活状态 */
onUpdateActive(active: boolean) {
}
/** HeroSkin 刷新字形皮后(升级 / refresh);子类按需隐藏字块自带武器等 */
onHeroGlyphSkinRefreshed(): void {
}
/** 播放闲置动画 */
onIdle() {
}
/** 普攻:默认走 SkillPresenterRouter(按 Skill.atk_type) */
onAttack(ctx: SkillPresentationContext) {
this.dispatchSkillPresentation(ctx);
}
/** 英雄主动:与普攻同一 Router */
onSkill(ctx: SkillPresentationContext) {
this.dispatchSkillPresentation(ctx);
}
/** 武器主动:与普攻同一 Router */
onWeaponSkill(ctx: SkillPresentationContext) {
this.dispatchSkillPresentation(ctx);
}
/** 分发到表现 Router(三槽共用);类型 3/4/6 在 atk_type/skillId 表现之后叠加 */
dispatchSkillPresentation(ctx: SkillPresentationContext): void {
this._showEffectGate = this._showEffect.arm(ctx, this._onShowEffectApplyBound);
this._showEffectApplied = !this._showEffectGate;
this._presAnimWaitForShow = false;
SkillPresenterRouter.instance.dispatch(ctx, this);
if (!this._showEffectGate) this._applyExtraOnCast(ctx);
}
/** 预兆命中时刻:施加额外效果并 drain 命中 */
private readonly _onShowEffectApplyBound = (): void => {
this._applyShowEffectGatedEffects();
};
/**
* 英雄/武器主动额外 3/4/6。
* @param ctx 表现上下文
*/
private _applyExtraOnCast(ctx: SkillPresentationContext): void {
getAllySkillExtraHost().onSkillCast(ctx);
getFaXiangHost().onSkillCast(ctx, this);
}
/** 预兆命中时刻:额外效果 + 逻辑命中;若英雄动画已完则收招 */
private _applyShowEffectGatedEffects(): void {
if (!this.isValid || this.isPresentationTeardown() || this._showEffectApplied) return;
this._showEffectApplied = true;
const ctx = this._presCtx;
if (ctx?.isAlive) {
this._applyExtraOnCast(ctx);
if (this._presMultiHitArmed && this._presMultiHitCtx) {
this._presMultiHitArmed = false;
this.schedulePresentation(this._onPresMultiHitTick, 0);
} else if (!this._presMultiHitCtx) {
while (ctx.isAlive && ctx.hit()) {
/* drain deferred DealDamage / atk_buff */
}
}
}
if (this._presAnimWaitForShow && !this._presMultiHitCtx) {
this._presAnimWaitForShow = false;
this.finishPresentationCast(this._presSeq, false);
}
}
/**
* 仅推进逻辑已锁定的 deferred 伤害(无动画)。
* Presenter 回退 / 子类需要时调用。
*/
drainLogicPresentation(ctx: SkillPresentationContext): void {
this._defaultLogicPresentation(ctx);
}
/**
* 默认表现:只推进逻辑已锁定的 deferred 伤害,不通过碰撞算目标。
* 子类播完动画后应同样 hit/finish,勿用 SkillCollisionHit 作为唯一伤害源。
*/
protected _defaultLogicPresentation(ctx: SkillPresentationContext): void {
if (!ctx.isAlive) return;
while (ctx.isAlive && ctx.hit()) {
/* drain deferred DealDamage */
}
if (ctx.isAlive) ctx.finish();
}
/** 开始一次 Router 播控,返回序号 */
beginPresentationCast(ctx: SkillPresentationContext): number {
const prev = this._presCtx;
if (prev?.isAlive && prev !== ctx && !this._hasBowLandJob(prev)) {
prev.cancel('superseded');
}
this._presFinished = false;
this._presAnimComplete = false;
this._presHoldUntilFxComplete = false;
this._presBowLand = false;
this._presBowLandId = 0;
this._presCtx = ctx;
const seq = ++this._presSeq;
this._presAnimSeq = seq;
return seq;
}
/**
* 字形动画播完不收招,等攻效(Spine)播完由攻效侧 markStaffSmashAnimsComplete 收招。
* 环形刀光(棍 200005)等「攻效播完才发射」的表现使用。
*/
holdPresentationUntilFxComplete(): void {
this._presHoldUntilFxComplete = true;
}
/** 冲锋等:本次播控不由武器/字形动画 complete 收招 */
disarmAnimComplete(): void {
this._presAnimSeq = 0;
}
/** 当前播控序号是否仍有效 */
isPresentationCast(seq: number): boolean {
return seq === this._presSeq && !this._presFinished;
}
/** 多段范围技(14 / 16 / 20):不门闩预兆,伤害出手即起跳 */
private _isMultiSegRangeAtk(): boolean {
const ctx = this._presCtx;
if (!ctx) return false;
const head = Math.floor(Number(ctx.params.atkTypeHead ?? ctx.params.atkType) || 0);
return head === 14 || head === 16 || head === 20;
}
/**
* 是否有该 ctx 的待落地弓箭(长飞距时下一击不得 cancel)。
* @param ctx 施法上下文
*/
private _hasBowLandJob(ctx: SkillPresentationContext): boolean {
for (let i = 0; i < this._bowLandJobs.length; i++) {
if (this._bowLandJobs[i].ctx === ctx) return true;
}
return false;
}
/**
* atk_type=4 弓:登记落地任务。伤害只在落地 dealDamage;标记保持到收招,动画结束不 drain。
* @param ctx 施法上下文
* @param target 锁定目标
* @returns 落地任务 id,交给飞箭
*/
armBowProjectileLand(ctx: SkillPresentationContext, target: EntityId): number {
const id = ++this._bowLandSeq;
this._bowLandJobs.push({ id, ctx, target });
this._presBowLand = true;
this._presBowLandId = id;
return id;
}
/**
* 弓箭落地:按 landId 结算对应任务;目标已死不结算。
* @param target 飞箭带回的锁定目标
* @param landId 落地任务 id
*/
readonly onBowProjectileLandBound = (target: EntityId, landId = 0): void => {
const job = this._takeBowLandJob(landId, target);
if (!job) return;
// 落地只清任务 id;_presBowLand 保持到收招,避免近距箭先落地后动画结束再 drain 第二段
if (this._presBowLandId === job.id) this._presBowLandId = 0;
const dest = target > 0 ? target : job.target;
if (!job.ctx.isAlive || dest <= 0) return;
if (!SkillPresentationEnv.isPresentTargetValid(dest)) return;
job.ctx.setTargets([dest]);
job.ctx.dealDamage(dest);
};
/**
* 取出并移除落地任务。
* @param landId 优先按 id;≤0 时按目标 FIFO
* @param target 飞箭目标
*/
private _takeBowLandJob(landId: number, target: EntityId): BowLandJob | null {
if (landId > 0) {
for (let i = 0; i < this._bowLandJobs.length; i++) {
if (this._bowLandJobs[i].id !== landId) continue;
const job = this._bowLandJobs[i];
this._bowLandJobs.splice(i, 1);
return job;
}
}
for (let i = 0; i < this._bowLandJobs.length; i++) {
if (target > 0 && this._bowLandJobs[i].target !== target) continue;
const job = this._bowLandJobs[i];
this._bowLandJobs.splice(i, 1);
return job;
}
return null;
}
/** 打断时作废全部待落地弓箭结算 */
private _cancelBowLandJobs(): void {
for (let i = 0; i < this._bowLandJobs.length; i++) {
const ctx = this._bowLandJobs[i].ctx;
if (ctx.isAlive) ctx.cancel('bow_land_aborted');
}
this._bowLandJobs.length = 0;
this._presBowLand = false;
this._presBowLandId = 0;
}
/** 延迟后只结算 hit,等动画播完再 finish(避免普攻重播掐断当前动作) */
schedulePresentationCast(seq: number, delayLogicalSec: number): void {
if (this._showEffectGate && !this._showEffectApplied) return;
this._presCastAwaitSeq = seq;
this.schedulePresentation(this._onPresCastHitTick, delayLogicalSec);
}
/** 多段 hit:按 hitIntervalSec 逐段推进 deferred 切片(atk_type=5 / 10 / 14 / 16) */
schedulePresentationMultiHit(seq: number, ctx: SkillPresentationContext, firstDelayLogicalSec: number): void {
this._presMultiHitSeq = seq;
this._presMultiHitCtx = ctx;
this._presMultiHitIndex = 0;
this._presSmashPool.length = 0;
const atkHead = Math.floor(Number(ctx.params.atkTypeHead ?? ctx.params.atkType) || 0);
if (atkHead === 10) {
const locked = ctx.targets;
for (let i = 0; i < locked.length; i++) this._presSmashPool.push(locked[i]);
}
if (atkHead !== 14 && atkHead !== 16 && atkHead !== 20 && this._showEffectGate && !this._showEffectApplied) {
this._presMultiHitArmed = true;
return;
}
this._presMultiHitArmed = false;
this.schedulePresentation(this._onPresMultiHitTick, firstDelayLogicalSec);
}
/**
* 先登记 12/13/17 弹道形态(须在 playCastAnim 之前),避免动画同步完播提前 finish。
* @param seq 播控序号
* @param shape 12 / 13 / 17
* @returns 是否登记成功
*/
prepareBowShapeLaunch(seq: number, shape: number): boolean {
if (seq !== this._presSeq || this._presFinished) return false;
this._presCastAwaitSeq = seq;
this._presBowShape = shape;
this._scatterLaunched = false;
this._presAnimWait = false;
this._volleyLeft = 0;
if (shape === 13) {
const ctx = this._presCtx;
if (!ctx || !this._setupVolley(ctx)) {
this._presBowShape = 0;
return false;
}
}
return true;
}
/**
* atk_type 12/13/17:出手时刻发射弹道,不 drain deferred。
* 若尚未 prepare,会先登记形态。
* @param seq 播控序号
* @param delayLogicalSec 首次出手延迟
* @param shape 12 / 13 / 17
*/
scheduleBowShapeLaunch(seq: number, delayLogicalSec: number, shape: number): void {
if (this._presBowShape !== shape || this._presCastAwaitSeq !== seq) {
if (!this.prepareBowShapeLaunch(seq, shape)) {
this.finishPresentationCast(seq, false);
return;
}
}
if (this._presFinished || seq !== this._presSeq) return;
this.schedulePresentation(this._onBowShapeTick, delayLogicalSec);
}
/** 结算当前 Router 播控 */
finishPresentationCast(seq: number, drainHits: boolean): void {
if (this._presFinished || seq !== this._presSeq) return;
this._presFinished = true;
const ctx = this._presCtx;
this._presCtx = null;
this._presSmashPool.length = 0;
if (ctx?.isAlive) {
if (drainHits) {
while (ctx.isAlive && ctx.hit()) {
/* drain */
}
}
ctx.finish();
}
if (this.isValid && !this.isPresentationBusy()) this.onIdle();
}
/**
* 连砸独立实例全部播完。未打完段则等命中;已打完则收招。
* @param seq 播控序号
*/
markStaffSmashAnimsComplete(seq: number): void {
if (this._presFinished || seq !== this._presSeq) return;
this._presAnimComplete = true;
if (this._presMultiHitCtx) return;
this.finishPresentationCast(seq, false);
}
/** 动画播完:解锁施法,允许下一击 */
readonly onPresentationAnimCompleteBound = (): void => {
if (this._presAnimSeq <= 0 || this._presAnimSeq !== this._presSeq) return;
this._presAnimComplete = true;
if (this._presMultiHitCtx) return;
// 环形刀光等:等攻效播完再收招发射,字形动画播完不 finish
if (this._presHoldUntilFxComplete) return;
// 连发/散射:动画结束绝不提前 finish;只标记等待,射完再收招
if (this._presBowShape === 13) {
this._presAnimWait = true;
if (this._volleyLeft <= 0) this._finishBowShape();
return;
}
if ((this._presBowShape === 12 || this._presBowShape === 17) && !this._scatterLaunched) {
this._presAnimWait = true;
return;
}
if (this._showEffectGate && !this._showEffectApplied && !this._isMultiSegRangeAtk()) {
this._presAnimWaitForShow = true;
return;
}
const drain = this._presBowShape !== 12 && this._presBowShape !== 13 && !this._presBowLand;
this._presBowShape = 0;
this.finishPresentationCast(this._presSeq, drain);
};
private _presCastAwaitSeq = 0;
private _presMultiHitSeq = 0;
private _presMultiHitIndex = 0;
private _presMultiHitCtx: SkillPresentationContext | null = null;
/** atk_type=14 当前脉冲用的表现上下文 */
private _presCirclePulseCtx: SkillPresentationContext | null = null;
/** atk_type=14 当前脉冲伤害倍率 */
private _presCirclePulseScale = 0;
/** 12 散射 / 13 连发 */
private _presBowShape = 0;
/** 散射是否已出手 */
private _scatterLaunched = false;
/** 连发剩余发数 */
private _volleyLeft = 0;
/** 连发已出手序号(从 0) */
private _volleyIndex = 0;
/** 连发间隔秒 */
private _volleyInterval = 0;
/** 连发锁定目标 */
private _volleyTarget: EntityId = 0;
/** 连发伤害倍率 */
private _volleyScale = 0;
/** 连发瞄准点(目标死后仍用最后坐标射完) */
private readonly _volleyAim = new Vec3();
/** 是否已有有效瞄准点 */
private _volleyHasAim = false;
/** 动画已结束、等待弹道出手完再 finish */
private _presAnimWait = false;
private _onPresCastHitTick = (): void => {
if (this._presFinished || this._presCastAwaitSeq !== this._presSeq) return;
const ctx = this._presCtx;
if (!ctx?.isAlive) return;
while (ctx.isAlive && ctx.hit()) {
/* drain deferred DealDamage */
}
};
private _onPresMultiHitTick = (): void => {
const seq = this._presMultiHitSeq;
const ctx = this._presMultiHitCtx;
if (!ctx || !this.isPresentationCast(seq) || !ctx.isAlive) {
this._finishMultiHitCast(seq, ctx);
return;
}
const atkHead = Math.floor(Number(ctx.params.atkTypeHead ?? ctx.params.atkType) || 0);
if (atkHead === 10 && !this._prepareSequentialSmash(ctx)) {
this._finishMultiHitCast(seq, ctx, true);
return;
}
if (atkHead === 14) this._pulseCasterCircleHit(ctx);
else ctx.hit();
const hitCount = Math.max(1, Math.floor(Number(ctx.params.hitCount) || 1));
const next = this._presMultiHitIndex + 1;
if (next >= hitCount) {
this._finishMultiHitCast(seq, ctx);
return;
}
this._presMultiHitIndex = next;
const interval = Math.max(0, Number(ctx.params.hitIntervalSec) || 0);
this.schedulePresentation(this._onPresMultiHitTick, interval);
};
/**
* atk_type=14:以施法者为圆心、射程为半径,本段重新索敌并结算。
* @param ctx 表现上下文
*/
private _pulseCasterCircleHit(ctx: SkillPresentationContext): void {
const session = SkillPresentationEnv.getBattleSession();
if (!session || !session.world.isAlive(ctx.caster)) return;
const center = logicPosOf(session, ctx.caster);
if (!center) {
console.log('[Skill] atk_type=14 无释放点', ctx.params.skillId);
return;
}
let radius = Math.max(0, Number(ctx.params.stageRangePx) || 0);
if (!(radius > 0)) {
radius = Math.max(0, session.attributes.getAttr(ctx.caster, AttrId.Range) || 0);
}
if (!(radius > 0)) {
console.log('[Skill] atk_type=14 无有效射程', ctx.params.skillId);
return;
}
const wan = Number(ctx.params.damageWan);
if (!Number.isFinite(wan)) {
console.log('[Skill] atk_type=14 伤害万分比无效', ctx.params.skillId, ctx.params.atkPara);
return;
}
this._presCirclePulseCtx = ctx;
this._presCirclePulseScale = wan / 10000;
pulseHostileCircle(session, ctx.caster, center.x, center.y, radius, this._onCasterCircleHit);
this._presCirclePulseCtx = null;
}
private readonly _onCasterCircleHit = (id: EntityId): void => {
const ctx = this._presCirclePulseCtx;
if (!ctx?.isAlive) return;
ctx.dealDamage(id, { scale: this._presCirclePulseScale, damageType: 'caster_circle' });
};
/**
* 多段命中结束:atk_type=14 先停循环武器动画再收招。
* @param seq 播控序号
* @param ctx 表现上下文(可空)
* @param stopSmash 是否停连砸生成
*/
private _finishMultiHitCast(seq: number, ctx: SkillPresentationContext | null, stopSmash = false): void {
const atkHead = ctx
? Math.floor(Number(ctx.params.atkTypeHead ?? ctx.params.atkType) || 0)
: 0;
this._presMultiHitCtx = null;
if (stopSmash && ctx) getStaffSmashHost().stopSpawning(ctx.caster);
if (atkHead === 14) this.visualChannel.endHoldCast();
if (!this.isPresentationCast(seq)) return;
if (this._presAnimSeq === seq && !this._presAnimComplete) return;
this.finishPresentationCast(seq, false);
}
/**
* atk_type=10:本段只打一个存活单体。无存活目标返回 false。
* @param ctx 表现上下文
*/
private _prepareSequentialSmash(ctx: SkillPresentationContext): boolean {
const target = this._pickSmashTarget(this._presMultiHitIndex);
if (!(target > 0)) return false;
ctx.setTargets([target]);
return true;
}
/**
* 从开战快照按砸次取存活单体,不足则循环。
* @param smashIndex 第几次砸(从 0)
*/
private _pickSmashTarget(smashIndex: number): EntityId {
const pool = this._presSmashPool;
const n = pool.length;
if (n <= 0) return 0;
const start = smashIndex % n;
for (let k = 0; k < n; k++) {
const id = pool[(start + k) % n];
if (SkillPresentationEnv.isPresentTargetValid(id)
&& !SkillPresentationEnv.isSpawnProtected(id)
&& !SkillPresentationEnv.isBajiaoBlowRetreating(id)
&& !SkillPresentationEnv.isYanCaught(id)) {
return id;
}
}
return 0;
}
private readonly _onBowShapeTick = (): void => {
if (this._presFinished || this._presCastAwaitSeq !== this._presSeq) return;
const ctx = this._presCtx;
if (!ctx?.isAlive) return;
if (this._presBowShape === 12 || this._presBowShape === 17) {
this._doScatter(ctx);
return;
}
if (this._presBowShape === 13) this._doVolleyShot(ctx);
};
/**
* 解析连发参数(数量、间隔、伤害均来自 atk_para 配表)。
* @param ctx 表现上下文
* @returns 是否有效
*/
private _setupVolley(ctx: SkillPresentationContext): boolean {
const raw = String(ctx.params.atkPara ?? '');
const parts = raw.split(',').map((s) => Number(String(s).trim()));
const count = Math.floor(parts[0] || 0);
const interval = Number(parts[1]);
const wan = Number(parts[2]);
const target = ctx.targets[0] ?? 0;
if (!(count > 0) || !Number.isFinite(interval) || !(interval >= 0) || !(wan > 0) || !(target > 0)) {
console.log('[Skill] atk_type=13 参数无效', target);
return false;
}
this._volleyLeft = count;
this._volleyIndex = 0;
this._volleyInterval = interval;
this._volleyTarget = target;
this._volleyScale = wan / 10000;
this._volleyHasAim = false;
const aim = SkillPresentationEnv.getTargetWorldPos(target);
if (aim) {
this._volleyAim.set(aim);
this._volleyHasAim = true;
}
return true;
}
/**
* 散射出手(12 穿透 / 17 不穿透)。
* @param ctx 表现上下文
*/
private _doScatter(ctx: SkillPresentationContext): void {
const parts = String(ctx.params.atkPara ?? '').split(',').map(Number);
const count = Math.floor(parts[0] || 0);
const stepDeg = parts[1];
const wan = parts[2];
const target = ctx.targets[0] ?? 0;
const head = Math.floor(Number(ctx.params.atkTypeHead ?? ctx.params.atkType) || 0);
const pierce = head !== 17;
const rangePx = SkillPresentationEnv.getCasterAttr(ctx.caster, AttrId.Range);
if (!(count > 0) || !(stepDeg > 0) || !(wan > 0) || !(rangePx > 0) || !(target > 0)) {
console.log('[Skill] atk_type=12/17 参数无效', ctx.params.atkPara, rangePx, target);
} else {
this.visualChannel.launchScatterArrows(ctx.caster, ctx.abilityId, target, count, stepDeg, rangePx, wan / 10000, String(ctx.params.buff1 ?? ''), String(ctx.params.buff2 ?? ''), pierce);
}
this._scatterLaunched = true;
if (this._presAnimWait) this._finishBowShape();
}
/**
* 连发一箭;配表发数打满,目标死后仍飞向最后瞄准点,落地不结算。
* @param ctx 表现上下文
*/
private _doVolleyShot(ctx: SkillPresentationContext): void {
const liveAim = SkillPresentationEnv.getTargetWorldPos(this._volleyTarget);
if (liveAim) {
this._volleyAim.set(liveAim);
this._volleyHasAim = true;
}
if (!this._volleyHasAim) {
console.log('[Skill] atk_type=13 无瞄准点', this._volleyTarget);
this._finishBowShape();
return;
}
const ok = this.visualChannel.launchVolleyShot(ctx.caster, ctx.abilityId, this._volleyTarget, this._volleyScale, String(ctx.params.buff1 ?? ''), String(ctx.params.buff2 ?? ''), this._volleyIndex, this._volleyAim);
if (!ok) {
this._finishBowShape();
return;
}
this._volleyIndex += 1;
this._volleyLeft -= 1;
if (this._volleyLeft > 0) {
this.schedulePresentation(this._onBowShapeTick, this._volleyInterval);
return;
}
if (this._presAnimWait) this._finishBowShape();
}
/** 结束 12/13 播控(不 drain);连发解除 holdCasting */
private _finishBowShape(): void {
this._volleyLeft = 0;
this._presBowShape = 0;
this.visualChannel.endHoldCast();
this.finishPresentationCast(this._presSeq, false);
}
/** 打断 Router 播控 */
protected abortPresentationCast(): void {
++this._presSeq;
this._presFinished = true;
this._presAnimSeq = 0;
this._presAnimComplete = false;
this._presMultiHitCtx = null;
this._presMultiHitArmed = false;
this._presSmashPool.length = 0;
this._presBowShape = 0;
this._volleyLeft = 0;
this._scatterLaunched = false;
this._presAnimWait = false;
this._presAnimWaitForShow = false;
this._showEffectGate = false;
this._showEffectApplied = true;
this._cancelBowLandJobs();
const ctx = this._presCtx;
this._presCtx = null;
if (ctx != null) {
getStaffSmashHost().abortCaster(ctx.caster);
getGroundCircleHost().abortCaster(ctx.caster);
this.visualChannel.endHoldCast();
}
if (ctx?.isAlive) ctx.cancel('aborted');
}
/**
* 地图字形 Spine 皮肤(五行字等):
* - 非激活 → 普通字
* - 激活 → 皮肤字1_3(1–3 级)/ 皮肤字1_4(4 级)/ 皮肤字1_5(5 级)
*/
applyComposeTextSkin(active: boolean, level: number): void {
const anim = this.getComposeTextAnim();
if (!anim?.isValid || !anim.skeletonData) return;
ensureSpineSkeletonDataRuntime(anim.skeletonData);
const lv = Math.max(1, Math.floor(level));
const skinName = active ? resolveActiveComposeSkin(lv) : COMPOSE_TEXT_SKIN_IDLE;
if (!applyComposeSpineSkin(anim, skinName)) return;
const cur = anim.getCurrent?.(0);
const rawName = cur?.animation?.name;
const animName = (typeof rawName === 'string' && rawName.length > 0 && rawName !== '<None>')
? rawName
: '待机';
const loop = cur ? !!cur.loop : true;
if (!BattleSpinePlayback.play(anim, animName, { loop, optional: true })) {
BattleSpinePlayback.play(anim, '待机', { loop: true, optional: true });
}
}
/** @deprecated 请用 applyComposeTextSkin;保留空实现兼容旧调用 */
onSetSkinByQuality(_quality: number) {
}
/**
* 伴生字纯表现:只播文本「攻击」再回待机,不碰 hit/finish。
* 由 SkillController 在主战字 onAttack/onSkill 时同步调用。
*/
playPartnerAttackVisual(timeScale = 1): void {
const anim = this.getComposeTextAnim();
if (!anim?.isValid) return;
const combined = this.combinePresentationTimeScale(timeScale);
this.playSpine(anim, '攻击', () => {
if (anim?.isValid) {
BattleSpinePlayback.play(anim, '待机', { loop: true, optional: true });
}
}, combined, false);
}
/** 局内战斗倍速(与 BattleRuntime.TimeScale 一致) */
protected getBattleTimeScale(): number {
return getBattleTimeScale();
}
/** 攻速系数 × 战斗倍速,供 Spine / 位移表现 */
protected combinePresentationTimeScale(attackSpeedScale = 1): number {
return Math.max(0.01, attackSpeedScale) * Math.max(0.01, this.getBattleTimeScale());
}
/**
* 战斗逻辑时间延迟(随 2 倍速等比缩短;走 BattleClock,不用 Cocos scheduleOnce 墙钟)。
* 参数顺序与 scheduleOnce 一致:(回调, 延迟秒)。
*/
protected schedulePresentation(fn: () => void, delayLogicalSec: number): void {
if (delayLogicalSec <= 0) {
fn();
return;
}
schedulePresentationClock(delayLogicalSec, fn);
}
/**
* 持续型 VFX:逻辑秒 hold 后淡出并销毁(禁止 tween.delay 墙钟)。
*/
protected scheduleOpacityLifetime(
node: Node,
holdLogicalSec: number,
fadeLogicalSec: number,
onBeforeDestroy?: () => void,
): void {
if (!node?.isValid) return;
const uo = node.getComponent(UIOpacity) ?? node.addComponent(UIOpacity);
Tween.stopAllByTarget(uo);
Tween.stopAllByTarget(node);
uo.opacity = 255;
const holdSec = Math.max(0.05, holdLogicalSec);
this.schedulePresentation(() => {
if (!uo?.isValid || !node?.isValid) return;
const fadeDur = fadeLogicalSec / Math.max(0.01, this.getBattleTimeScale());
tween(uo).to(fadeDur, { opacity: 0 }).start();
}, holdSec);
this.schedulePresentation(() => {
onBeforeDestroy?.();
if (node?.isValid) {
Tween.stopAllByTarget(uo);
node.destroy();
}
}, holdSec + fadeLogicalSec);
}
/** flyLayer 内直线飞行(SkillTransformDriver,随战斗倍速) */
protected animateLinearFlyInLayer(
flyLayer: Node,
fly: Node,
fromWorld: Readonly<Vec3>,
toWorld: Readonly<Vec3>,
durationLogicalSec: number,
onComplete: () => void,
angleOpts?: { fromAngle?: number; toAngle?: number },
): void {
const flyUt = flyLayer.getComponent(UITransform);
const startLocal = flyUt
? flyUt.convertToNodeSpaceAR(new Vec3(fromWorld.x, fromWorld.y, fromWorld.z))
: fly.position.clone();
const endLocal = flyUt
? flyUt.convertToNodeSpaceAR(new Vec3(toWorld.x, toWorld.y, toWorld.z))
: startLocal.clone();
fly.setPosition(startLocal);
const fromAngle = angleOpts?.fromAngle ?? fly.angle;
const toAngle = angleOpts?.toAngle ?? fromAngle;
SkillTransformDriver.cancelNode(fly);
SkillTransformDriver.animate({
node: fly,
duration: durationLogicalSec,
fromPos: startLocal,
toPos: endLocal,
fromAngle,
toAngle,
easing: SkillTransformDriver.easeLinear,
onComplete,
});
}
bindEntity(_entityId: EntityId): void {
}
/** 回池前拆下攻技节点,避免嵌进 heroTemplate 池 */
detachPooledFx(): void {
}
/** 移动/拆塔等打断未结束的表现(子类 override 时须调 super 或 abortPresentationCast) */
abortPresentation(): void {
this.abortPresentationCast();
}
/** Router 播控进行中 */
isPresentationBusy(): boolean {
return this._presCtx != null && !this._presFinished;
}
/** 挂到 flyLayer 等、不在 this.node 下的表现节点;暂停时一并冻 Spine */
protected collectPresentationFxNodes(out: Node[]): void {
const fx = this._showEffect.node;
if (fx) out.push(fx);
const caster = this._presCtx?.caster;
if (caster != null) getStaffSmashHost().collectNodes(out, caster);
}
/** 局内 UI 暂停:冻结 Spine 与 schedule,恢复后从当前帧续播 */
freezePresentation(): void {
if (!this.isValid) return;
director.getScheduler().pauseTarget(this as unknown as ISchedulable);
const frozen = new Map<sp.Skeleton, number>();
const roots: Node[] = [this.node];
this.collectPresentationFxNodes(roots);
for (const root of roots) {
if (!root?.isValid) continue;
for (const sk of root.getComponentsInChildren(sp.Skeleton)) {
if (!sk?.isValid || frozen.has(sk)) continue;
frozen.set(sk, sk.timeScale);
sk.timeScale = 0;
}
}
this._frozenSpineTimeScales = frozen;
}
/** 恢复 freezePresentation 冻结的表现 */
unfreezePresentation(): void {
if (!this.isValid) return;
director.getScheduler().resumeTarget(this as unknown as ISchedulable);
if (!this._frozenSpineTimeScales) return;
for (const [sk, ts] of this._frozenSpineTimeScales) {
if (sk?.isValid) sk.timeScale = ts;
}
this._frozenSpineTimeScales = null;
}
private _frozenSpineTimeScales: Map<sp.Skeleton, number> | null = null;
/** 组合字文本 Spine:优先子类 textAnim,否则按常见节点路径查找 */
protected getComposeTextAnim(): sp.Skeleton | null {
const owned = (this as Skill & { textAnim?: sp.Skeleton | null }).textAnim;
if (owned?.isValid) return owned;
return findComposeTextAnim(this.node);
}
/**
* 播放 Spine;非 loop 时播完触发 callback。
* 动画不存在时立即 callback,避免表现层等不到 complete → presentation 永久 locked。
*/
protected playSpine(
anim: sp.Skeleton,
name: string,
callback: Function = null,
timeScale = 1,
loop = true,
): boolean {
return playSkillSpine(anim, name, callback as (() => void) | null, timeScale, loop);
}
}
/**
* 播放 Spine(供 Skill 子类外的表现服务调用)。
* 非 loop 时播完触发 callback;动画不存在时立即 callback。
*/
export function playSkillSpine(
anim: sp.Skeleton,
name: string,
callback: (() => void) | null = null,
timeScale = 1,
loop = true,
): boolean {
return BattleSpinePlayback.play(anim, name, {
loop,
timeScale,
onComplete: callback ?? undefined,
});
}
function resolveActiveComposeSkin(level: number): string {
return resolveActiveGlyphSkin(level, COMPOSE_TEXT_SKIN_QUALITY);
}
function applyComposeSpineSkin(anim: sp.Skeleton, preferred: string): boolean {
const extras = preferred === COMPOSE_TEXT_SKIN_IDLE
? []
: [
...COMPOSE_ACTIVE_SKIN_BUCKETS
.map((b) => `皮肤字${COMPOSE_TEXT_SKIN_QUALITY}_${b}`)
.filter((name) => name !== preferred),
COMPOSE_TEXT_SKIN_IDLE,
];
return applyGlyphSpineSkin(anim, preferred, extras);
}
/** 查找字块文本 Spine(排除 atkParent/skillParent 下的武器) */
function findComposeTextAnim(root: Node | null): sp.Skeleton | null {
if (!root?.isValid) return null;
const paths = [
'anim/text', 'text',
'anim/沙', 'anim/僧', 'anim/悟', 'anim/空',
'anim/八', 'anim/戒', 'anim/白', 'anim/龙', 'anim/马',
'anim/阎', 'anim/王', 'anim/哪', 'anim/吒', 'anim/嫦', 'anim/娥',
'anim/杨', 'anim/戬',
'anim/轮',
'anim/火',
];
for (const p of paths) {
const sk = root.find(p)?.getComponent(sp.Skeleton);
if (sk) return sk;
}
const anim = root.getChildByName('anim');
if (!anim?.isValid) return null;
// 僧等:字 Spine 挂在 anim/skill,且无武器父节点
if (!anim.getChildByName('skillParent') && !anim.getChildByName('atkParent')) {
return anim.getChildByName('skill')?.getComponent(sp.Skeleton) ?? null;
}
return null;
}