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.
478 lines
15 KiB
478 lines
15 KiB
|
3 weeks ago
|
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;
|
||
|
|
}
|
||
|
|
}
|