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.
 
 
 
 
AdLike2048/assets/script/battle/presentation/StaffSmashPresenter.ts

477 lines
15 KiB

import { Node, UITransform, Vec3, sp } from 'cc';
import type { EntityId } from 'db://assets/mx/module/ecs';
import { GBundle } from '../../game/ConfigRes';
import type { Skill } from '../skill/Skill';
import { scaleSkillParentReach } from '../skill/SkillParentAim';
import { SkillPresentationEnv } from '../skill/SkillPresentationEnv';
import { computeAimAngleDeg } from '../skill/SkillTransformDriver';
import { applyWeaponSpineSkin } from '../view/GlyphSpineSkin';
import { WEAPON_REST_ANGLE } from '../view/HeroWeaponMount';
import { BattleSpinePlayback } from './BattleSpinePlayback';
/** Weapon.type → 预制体(与 HeroWeaponMount 一致) */
const WEAPON_TYPE_PREFAB: readonly string[] = [
'',
'weaponType1',
'weaponType2',
'weaponType3',
'weaponType4',
];
const WEAPON_PREFAB_PATH = 'prefab/weapon/';
const AIM_NODE = 'weapon';
const SKIN_NODE = 'skin';
/** 「金箍棒技能」砸出帧:金箍棒1–4 / 击中光出现 */
const SMASH_HIT_AT = 0.8667;
/** 砸击闪光骨(拉伸落点) */
const HIT_BONE = '击中光';
/** 一次独立砸击实例 */
interface StaffSmashFx {
/** 武器克隆 */
node: Node;
/** 施法者 */
caster: EntityId;
/** 播控序号 */
seq: number;
/** 回池剩余秒 */
wait: number;
}
/** 一轮连砸任务 */
interface StaffSmashJob {
/** 施法者 */
caster: EntityId;
/** 播控序号 */
seq: number;
/** Skill 宿主 */
skill: Skill;
/** 开战锁定目标 */
pool: EntityId[];
/** 下一砸下标 */
nextSmash: number;
/** 剩余砸次数 */
left: number;
/** 距下次生成秒 */
spawnWait: number;
/** 砸间隔秒 */
interval: number;
/** Skill.animation */
animation: string;
/** Weapon.skin */
skin: string;
/** Weapon.type */
weaponType: number;
/** 生成点世界坐标 */
ox: number;
/** 生成点世界 Y */
oy: number;
/** 生成点世界 Z */
oz: number;
/** 世界缩放 X */
sx: number;
/** 世界缩放 Y */
sy: number;
/** 世界缩放 Z */
sz: number;
}
/** 开战参数 */
export interface StaffSmashBegin {
/** 施法者 */
caster: EntityId;
/** 播控序号 */
seq: number;
/** Skill 宿主 */
skill: Skill;
/** 开战锁定目标 */
pool: readonly EntityId[];
/** 砸次数 */
hitCount: number;
/** 间隔秒 */
intervalSec: number;
/** Skill.animation */
animation: string;
/** Weapon.skin */
skin: string;
/** Weapon.type */
weaponType: number;
/** 原武器根(取世界坐标与缩放) */
fromNode: Node;
}
let _host: StaffSmashHost | null = null;
/** 金箍棒等连砸:每次独立武器实例 */
export function getStaffSmashHost(): StaffSmashHost {
if (!_host) _host = new StaffSmashHost();
return _host;
}
/**
* atk_type=10 连砸实例:每次 getNode 一根武器,播完回池,互不打断。
*/
export class StaffSmashHost {
private readonly _fx: StaffSmashFx[] = [];
private readonly _jobs: StaffSmashJob[] = [];
private readonly _from = new Vec3();
private readonly _scale = new Vec3();
private readonly _to = new Vec3();
/** 击中光世界点 */
private readonly _hit = new Vec3();
/** 骨本地 scratch */
private readonly _boneLocal = new Vec3();
/** 朝向节点未拉伸的本地缩放 */
private readonly _aimInit = new Vec3(1, 1, 1);
/**
* 开始一轮连砸。同施法者未结束的上一轮先清掉。
* @param spec 开战参数
*/
begin(spec: StaffSmashBegin): void {
this.abortCaster(spec.caster);
const hitCount = Math.max(0, Math.floor(spec.hitCount));
if (!(hitCount > 0)) {
console.log('[StaffSmash] 砸次数无效', spec.hitCount);
spec.skill.markStaffSmashAnimsComplete(spec.seq);
return;
}
const from = spec.fromNode;
if (!from?.isValid) {
console.log('[StaffSmash] 无武器根节点');
spec.skill.markStaffSmashAnimsComplete(spec.seq);
return;
}
const wp = from.worldPosition;
const ws = from.worldScale;
const pool: EntityId[] = [];
for (let i = 0; i < spec.pool.length; i++) pool.push(spec.pool[i]);
const job: StaffSmashJob = {
caster: spec.caster,
seq: spec.seq,
skill: spec.skill,
pool,
nextSmash: 0,
left: hitCount,
spawnWait: 0,
interval: Math.max(0, spec.intervalSec),
animation: String(spec.animation ?? '').trim(),
skin: String(spec.skin ?? '').trim(),
weaponType: spec.weaponType,
ox: wp.x,
oy: wp.y,
oz: wp.z,
sx: ws.x,
sy: ws.y,
sz: ws.z,
};
this._jobs.push(job);
this._spawnOne(job);
}
/**
* 推进生成与回池。
* @param dt 逻辑秒
*/
tick(dt: number): void {
if (!(dt > 0)) return;
for (let i = this._jobs.length - 1; i >= 0; i--) {
const job = this._jobs[i];
if (job.left <= 0) continue;
job.spawnWait -= dt;
if (job.spawnWait > 0) continue;
this._spawnOne(job);
}
for (let i = this._fx.length - 1; i >= 0; i--) {
const fx = this._fx[i];
if (!fx.node?.isValid) {
this._fx.splice(i, 1);
continue;
}
fx.wait -= dt;
if (fx.wait <= 0) this._endFx(i);
}
this._notifyIdleJobs();
}
/**
* 打断某施法者全部连砸实例。
* @param caster 施法者
*/
abortCaster(caster: EntityId): void {
for (let i = this._jobs.length - 1; i >= 0; i--) {
if (this._jobs[i].caster === caster) this._jobs.splice(i, 1);
}
for (let i = this._fx.length - 1; i >= 0; i--) {
if (this._fx[i].caster === caster) this._endFx(i);
}
}
/** 取消全部连砸并回池 */
abortAll(): void {
this._jobs.length = 0;
for (let i = this._fx.length - 1; i >= 0; i--) this._endFx(i);
}
/**
* 目标已尽:停后续生成,已出实例播完再通知收招。
* @param caster 施法者
*/
stopSpawning(caster: EntityId): void {
for (let i = 0; i < this._jobs.length; i++) {
if (this._jobs[i].caster === caster) this._jobs[i].left = 0;
}
this._notifyIdleJobs();
}
/**
* 收集该施法者仍在播的克隆(暂停冻 Spine)。
* @param out 输出
* @param caster 施法者
*/
collectNodes(out: Node[], caster: EntityId): void {
for (let i = 0; i < this._fx.length; i++) {
const fx = this._fx[i];
if (fx.caster !== caster) continue;
if (fx.node?.isValid) out.push(fx.node);
}
}
/**
* 生成一次独立砸击。
* @param job 任务
*/
private _spawnOne(job: StaffSmashJob): void {
if (job.left <= 0) return;
job.left -= 1;
job.spawnWait = job.interval;
const smashIndex = job.nextSmash;
job.nextSmash += 1;
const target = this._pickTarget(job.pool, smashIndex);
const node = this._spawnNode(job);
if (!node) {
console.log('[StaffSmash] 实例未生成', job.weaponType, smashIndex);
return;
}
const aim = node.getChildByName(AIM_NODE);
const sk = aim?.getChildByName(SKIN_NODE)?.getComponent(sp.Skeleton)
?? node.getComponentInChildren(sp.Skeleton);
if (!aim?.isValid || !sk?.isValid) {
console.log('[StaffSmash] 克隆缺朝向/Spine');
this._recycle(node);
return;
}
if (job.skin) applyWeaponSpineSkin(sk, job.skin, '[StaffSmash]');
const anim = job.animation || '攻击';
this._aimAt(aim, node, target);
this._stretchToTarget(aim, sk, anim, target);
const wait = BattleSpinePlayback.resolveLogicalCompleteDelay(sk, anim);
if (wait == null) {
console.log('[StaffSmash] 动画不存在', anim);
this._recycle(node);
return;
}
if (!BattleSpinePlayback.play(sk, anim, { loop: false })) {
this._recycle(node);
return;
}
this._fx.push({ node, caster: job.caster, seq: job.seq, wait });
}
/**
* 从池取存活目标,不足循环。
* @param pool 开战快照
* @param smashIndex 第几次
*/
private _pickTarget(pool: readonly EntityId[], smashIndex: number): EntityId {
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;
}
/**
* 取武器克隆放到飞行层,对齐原武器世界坐标。
* @param job 任务
*/
private _spawnNode(job: StaffSmashJob): Node | null {
const prefab = WEAPON_TYPE_PREFAB[job.weaponType] ?? '';
if (!prefab) {
console.log('[StaffSmash] 武器类型无预制体', job.weaponType);
return null;
}
const parent = SkillPresentationEnv.flyLayer ?? SkillPresentationEnv.getMap()?.ensureHeroParent();
if (!parent?.isValid) {
console.log('[StaffSmash] 无父节点');
return null;
}
let node: Node | null = null;
try {
node = gg.res.getNode(WEAPON_PREFAB_PATH, prefab, GBundle.BattleHero);
} catch (e) {
console.log('[StaffSmash] 加载异常', String(e));
return null;
}
if (!node?.isValid) {
console.log('[StaffSmash] 预制体未加载', prefab);
return null;
}
if (!parent.active) parent.active = true;
node.parent = parent;
node.active = true;
node.layer = parent.layer;
this._from.set(job.ox, job.oy, job.oz);
this._scale.set(job.sx, job.sy, job.sz);
node.setWorldScale(this._scale);
node.setWorldPosition(this._from);
return node;
}
/**
* 克隆朝向目标。
* @param aim 朝向节点
* @param root 根
* @param target 目标
*/
private _aimAt(aim: Node, root: Node, target: EntityId): void {
const world = target > 0 ? SkillPresentationEnv.getTargetWorldPos(target) : null;
if (!world) {
aim.angle = WEAPON_REST_ANGLE;
return;
}
this._to.set(world.x, world.y, world.z);
const deg = computeAimAngleDeg(aim.worldPosition, this._to)
?? computeAimAngleDeg(root.worldPosition, this._to);
if (deg == null) {
console.log('[StaffSmash] 朝向为零', target);
return;
}
aim.angle = deg;
}
/**
* 按砸出帧击中光到目标的距离,沿朝向 X 拉伸,避免远处伸不到。
* @param aim 朝向节点
* @param sk 武器 Spine
* @param anim Skill.animation
* @param target 目标
*/
private _stretchToTarget(aim: Node, sk: sp.Skeleton, anim: string, target: EntityId): void {
if (!(target > 0)) return;
const world = SkillPresentationEnv.getTargetWorldPos(target);
if (!world) return;
this._to.set(world.x, world.y, world.z);
if (!this._poseAtHit(sk, anim)) return;
if (!this._readBoneWorld(sk, HIT_BONE, this._hit)) {
console.log('[StaffSmash] 击中光骨骼缺失,无法按距离拉伸');
return;
}
const pivot = aim.worldPosition;
const baseReach = Math.hypot(this._hit.x - pivot.x, this._hit.y - pivot.y);
if (baseReach <= 1e-3) {
console.log('[StaffSmash] 砸出长度为零');
return;
}
this._aimInit.set(1, 1, 1);
scaleSkillParentReach(aim, pivot, this._to, baseReach, this._aimInit);
}
/**
* 把动画采样到砸出帧,刷新骨世界坐标。
* @param sk 武器 Spine
* @param anim 动画名
*/
private _poseAtHit(sk: sp.Skeleton, anim: string): boolean {
if (!sk?.isValid) return false;
if (typeof sk.setAnimationCacheMode === 'function') {
sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.REALTIME);
}
BattleSpinePlayback.clearPendingComplete(sk);
sk.timeScale = 1;
if (!sk.setAnimation(0, anim, false)) {
console.log('[StaffSmash] 无法采样砸出帧', anim);
return false;
}
sk.updateAnimation(SMASH_HIT_AT);
return true;
}
/**
* 读骨世界坐标。
* @param sk 武器 Spine
* @param boneName 骨名
* @param out 输出
*/
private _readBoneWorld(sk: sp.Skeleton, boneName: string, out: Vec3): boolean {
const bone = typeof sk.findBone === 'function' ? sk.findBone(boneName) : null;
const ui = sk.node?.getComponent(UITransform);
if (!bone || !ui?.isValid) return false;
this._boneLocal.set(bone.worldX, bone.worldY, 0);
ui.convertToWorldSpaceAR(this._boneLocal, out);
return true;
}
/**
* 任务砸完且实例播完:通知 Skill 可收招。
*/
private _notifyIdleJobs(): void {
for (let i = this._jobs.length - 1; i >= 0; i--) {
const job = this._jobs[i];
if (job.left > 0) continue;
if (this._hasFx(job.caster, job.seq)) continue;
this._jobs.splice(i, 1);
job.skill.markStaffSmashAnimsComplete(job.seq);
}
}
/**
* 是否还有该轮实例。
* @param caster 施法者
* @param seq 播控序号
*/
private _hasFx(caster: EntityId, seq: number): boolean {
for (let i = 0; i < this._fx.length; i++) {
if (this._fx[i].caster === caster && this._fx[i].seq === seq) return true;
}
return false;
}
/**
* 结束并回池。
* @param index 下标
*/
private _endFx(index: number): void {
const fx = this._fx[index];
this._fx.splice(index, 1);
this._recycle(fx.node);
}
/**
* 回池武器克隆。
* @param node 节点
*/
private _recycle(node: Node | null): void {
if (!node?.isValid) return;
const sk = node.getComponentInChildren(sp.Skeleton);
BattleSpinePlayback.clearPendingComplete(sk);
BattleSpinePlayback.resetTracks(sk);
const aim = node.getChildByName(AIM_NODE);
if (aim?.isValid) {
aim.angle = WEAPON_REST_ANGLE;
aim.setScale(1, 1, 1);
}
node.setScale(1, 1, 1);
node.setPosition(0, 0, 0);
gg.res.putNode(node);
const flyLayer = SkillPresentationEnv.flyLayer;
if (flyLayer?.isValid && flyLayer.children.length === 0) flyLayer.active = false;
}
}