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.
328 lines
12 KiB
328 lines
12 KiB
import { instantiate, Node, sp, UITransform, Vec3 } from 'cc';
|
|
import type { EntityId } from 'db://assets/mx/module/ecs';
|
|
import { AttrId, BattleTeamId, type BattleSession } from 'db://assets/mx/module/battle';
|
|
import {
|
|
HealthComp,
|
|
MovementComp,
|
|
TeamComp,
|
|
isBajiaoBlowRetreating,
|
|
isYanSoulClaimed,
|
|
} from 'db://assets/mx/module/battle/ecs/Components';
|
|
import { GBundle, GPath } from '../../game/ConfigRes';
|
|
import { SkillPresentationEnv } from '../skill/SkillPresentationEnv';
|
|
import { SkillTransformDriver } from '../skill/SkillTransformDriver';
|
|
import { SkillPresenterRouter } from './SkillPresenterRouter';
|
|
import { buildShadowDealContext } from './SkillShadowContext';
|
|
import type { Skill } from '../skill/Skill';
|
|
import type { SkillPresentationContext } from '../skill/SkillPresentationTypes';
|
|
import type { ISkillPresenter } from './SkillPresenter';
|
|
|
|
/** 龙影预制体(battleHero/prefab/skill/棍,子节点 Spine 剑气) */
|
|
const RING_PREFAB = '棍';
|
|
/** 龙影 Spine 兜底动画(预制体未勾 playOnLoad,需显式播放) */
|
|
const RING_ANIM = '剑气金';
|
|
/** 出生外扩兜底(像素):施法者攻击范围取不到时用 */
|
|
const RING_SPAWN_OFFSET_FALLBACK = 200;
|
|
/** 首中保底可视飞行时长(秒):贴脸命中不凭空消失 */
|
|
const RING_MIN_VISIBLE_SEC = 0.08;
|
|
/** 直伤类型 */
|
|
const DMG_TYPE = 'extra_ring';
|
|
|
|
/** Skill.skill type=10 解析结果 */
|
|
export interface RingProjectilesParams {
|
|
/** 起始角度(度,水平向右起逆时针) */
|
|
startDeg: number;
|
|
/** 数量:均分 360° */
|
|
count: number;
|
|
/** 发射间隔(秒) */
|
|
intervalSec: number;
|
|
/** 飞行速度(像素/秒) */
|
|
speed: number;
|
|
/** 飞行时间(秒),飞距 = 速度 × 时间 */
|
|
flyTimeSec: number;
|
|
/** 伤害系数(万分比) */
|
|
scale: number;
|
|
}
|
|
|
|
/**
|
|
* 解析 `10,起始角度,数量,间隔时间,飞行速度,飞行时间,伤害万分比`。
|
|
* @param raw Skill.skill
|
|
*/
|
|
export function parseRingProjectilesParams(raw: string): RingProjectilesParams | null {
|
|
const parts = String(raw ?? '').split(',').map((s) => Number(String(s).trim()));
|
|
if (parts[0] !== 10) return null;
|
|
const startDeg = parts[1];
|
|
const count = Math.floor(parts[2]);
|
|
const intervalSec = parts[3];
|
|
const speed = parts[4];
|
|
const flyTimeSec = parts[5];
|
|
const wan = parts[6];
|
|
if (!isFinite(startDeg) || !(count > 0) || !(intervalSec >= 0)
|
|
|| !(speed > 0) || !(flyTimeSec > 0) || !(wan > 0)) {
|
|
console.log('[ExtraRing] skill 参数无效(起始角,数量,间隔,速度,飞行时间,伤害)', raw);
|
|
return null;
|
|
}
|
|
return { startDeg, count, intervalSec, speed, flyTimeSec, scale: wan / 10000 };
|
|
}
|
|
|
|
/** 一次环形龙影发射 */
|
|
interface ExtraRingRun {
|
|
/** 施法者 */
|
|
caster: EntityId;
|
|
/** 表现上下文(dealDamage 用) */
|
|
ctx: SkillPresentationContext;
|
|
/** 发射间隔 */
|
|
intervalSec: number;
|
|
/** 飞行速度 */
|
|
speed: number;
|
|
/** 飞行距离(像素) */
|
|
flyDist: number;
|
|
/** 伤害系数 */
|
|
scale: number;
|
|
/** 各支角度(度) */
|
|
degs: number[];
|
|
/** 下一发下标 */
|
|
next: number;
|
|
/** 距下一发剩余秒 */
|
|
wait: number;
|
|
/** 出生世界坐标(施法瞬间定格) */
|
|
origin: Vec3;
|
|
/** 出生外扩(像素)= 佩戴单位攻击范围 */
|
|
spawnOffset: number;
|
|
}
|
|
|
|
/** 在飞一支龙影 */
|
|
interface ExtraRingFly {
|
|
/** 龙影节点 */
|
|
node: Node;
|
|
/** 所属 run */
|
|
run: ExtraRingRun;
|
|
/** 命中判定半径(像素,表现体外接圆) */
|
|
radius: number;
|
|
/** 已飞秒 */
|
|
age: number;
|
|
/** 已首中(等保底可视时长后回收) */
|
|
consumed: boolean;
|
|
}
|
|
|
|
let _host: ExtraRingHost | null = null;
|
|
|
|
/** 环形龙影运行时 */
|
|
export function getExtraRingHost(): ExtraRingHost {
|
|
if (!_host) _host = new ExtraRingHost();
|
|
return _host;
|
|
}
|
|
|
|
/** Skill.skill type=10:以自身为圆心环形发射龙影,首中即结算消耗,无怪飞满距离消失 */
|
|
export class ExtraRingHost {
|
|
private readonly _runs: ExtraRingRun[] = [];
|
|
private readonly _flies: ExtraRingFly[] = [];
|
|
|
|
/**
|
|
* 开战发射:先播本体表现(skillId/atk_type 派发),再起环形发射。
|
|
* @param ctx 表现上下文
|
|
* @param host Skill 宿主
|
|
*/
|
|
start(ctx: SkillPresentationContext, host: Skill): void {
|
|
const raw = String(ctx.params.extraSkill ?? ctx.params.skillRaw ?? '');
|
|
const params = parseRingProjectilesParams(raw);
|
|
if (!params) {
|
|
host.drainLogicPresentation(ctx);
|
|
return;
|
|
}
|
|
// 额外效果不抢 cast 锁:本体表现(如棍攻效)由原派发路径持有
|
|
SkillPresenterRouter.instance.dispatchBase(ctx, host);
|
|
// 上一轮还在逆时针发射中:不开新轮(本体表现照常),
|
|
// 避免高频普攻反复取消队列导致转圈永远不完整
|
|
if (this._runs.some((r) => r.caster === ctx.caster)) return;
|
|
// 出生圆心按施法者字的实际位置(组合字视觉中心),非组合宿主退回格中心
|
|
const origin = SkillPresentationEnv.getCompositionWorldCenter(ctx.caster)?.clone()
|
|
?? SkillPresentationEnv.getCasterGridCenter(ctx.caster)?.clone()
|
|
?? new Vec3();
|
|
// 出生外扩 = 佩戴单位攻击范围(AttrId.Range),取不到用兜底
|
|
const session0 = SkillPresentationEnv.getBattleSession();
|
|
const rangePx = session0 ? session0.attributes.getAttr(ctx.caster, AttrId.Range) : 0;
|
|
const spawnOffset = rangePx > 0 ? rangePx : RING_SPAWN_OFFSET_FALLBACK;
|
|
const step = 360 / params.count;
|
|
const degs: number[] = [];
|
|
for (let i = 0; i < params.count; i++) degs.push(params.startDeg + i * step);
|
|
this._runs.push({
|
|
caster: ctx.caster,
|
|
ctx: buildShadowDealContext(ctx, String(ctx.params.buff1 ?? ''), String(ctx.params.buff2 ?? '')),
|
|
intervalSec: params.intervalSec,
|
|
speed: params.speed,
|
|
flyDist: params.speed * params.flyTimeSec,
|
|
scale: params.scale,
|
|
degs,
|
|
next: 0,
|
|
wait: 0,
|
|
origin,
|
|
spawnOffset,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 推进发射、敌对命中与保底回收。
|
|
* @param dt 逻辑秒
|
|
*/
|
|
tick(dt: number): void {
|
|
if (!(dt > 0)) return;
|
|
for (let i = this._runs.length - 1; i >= 0; i--) {
|
|
const run = this._runs[i];
|
|
if (!run.ctx.isAlive) {
|
|
this._dropRun(run);
|
|
this._runs.splice(i, 1);
|
|
continue;
|
|
}
|
|
run.wait -= dt;
|
|
while (run.next < run.degs.length && run.wait <= 0) {
|
|
this._launch(run, run.degs[run.next]);
|
|
run.next++;
|
|
run.wait += run.intervalSec;
|
|
}
|
|
if (run.next >= run.degs.length) this._runs.splice(i, 1);
|
|
}
|
|
for (let i = this._flies.length - 1; i >= 0; i--) {
|
|
const fly = this._flies[i];
|
|
fly.age += dt;
|
|
if (!fly.consumed) this._hitScan(fly);
|
|
if (fly.consumed && fly.age >= RING_MIN_VISIBLE_SEC) this._finishFly(fly);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 敌对命中扫描:只对施法者敌对阵营的有血量单位生效
|
|
* (我方发射打怪、AI 法神发射打我方),免疫态与 ArrowRain 一致。
|
|
*/
|
|
private _hitScan(fly: ExtraRingFly): void {
|
|
const session = SkillPresentationEnv.getBattleSession();
|
|
if (!session) return;
|
|
const run = fly.run;
|
|
if (!session.world.isAlive(run.caster)) return;
|
|
const casterTeam = session.world.getComponent(run.caster, TeamComp);
|
|
const wp = fly.node.worldPosition;
|
|
const r2 = fly.radius * fly.radius;
|
|
session.world.view(HealthComp).forEach((id, hp) => {
|
|
if (fly.consumed) 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;
|
|
if (!BattleTeamId.isHostile(casterTeam, session.world.getComponent(id, TeamComp))) return;
|
|
const tp = SkillPresentationEnv.getTargetWorldPos(id);
|
|
if (!tp) return;
|
|
const dx = tp.x - wp.x;
|
|
const dy = tp.y - wp.y;
|
|
if (dx * dx + dy * dy > r2) return;
|
|
if (run.ctx.dealDamage(id, { scale: run.scale, damageType: DMG_TYPE })) fly.consumed = true;
|
|
});
|
|
}
|
|
|
|
/** 结束全部发射(战斗结束) */
|
|
abortAll(): void {
|
|
for (let i = this._runs.length - 1; i >= 0; i--) this._dropRun(this._runs[i]);
|
|
this._runs.length = 0;
|
|
for (let i = this._flies.length - 1; i >= 0; i--) this._finishFly(this._flies[i]);
|
|
}
|
|
|
|
/** 丢掉未发射队列(在飞的不受影响,飞完自然消失) */
|
|
private _dropRun(run: ExtraRingRun): void {
|
|
run.next = run.degs.length;
|
|
}
|
|
|
|
/**
|
|
* 发一支龙影。
|
|
* @param run 运行态
|
|
* @param deg 发射角度
|
|
*/
|
|
private _launch(run: ExtraRingRun, deg: number): void {
|
|
if (!run.ctx.isAlive) return;
|
|
const flyLayer = SkillPresentationEnv.flyLayer;
|
|
const flyUt = flyLayer?.getComponent(UITransform);
|
|
if (!flyLayer?.isValid || !flyUt) return;
|
|
const rad = deg * Math.PI / 180;
|
|
const ux = Math.cos(rad);
|
|
const uy = Math.sin(rad);
|
|
const from = new Vec3(
|
|
run.origin.x + ux * run.spawnOffset,
|
|
run.origin.y + uy * run.spawnOffset,
|
|
run.origin.z,
|
|
);
|
|
const to = new Vec3(from.x + ux * run.flyDist, from.y + uy * run.flyDist, from.z);
|
|
const node = this._spawnClone(deg);
|
|
if (!node) return;
|
|
// 模板无父节点,worldScale 即期望尺寸;挂 flyLayer 后按世界缩放摆回
|
|
const worldScale = node.worldScale.clone();
|
|
// 命中半径 = 表现体外接圆(对角线一半 × 世界缩放)
|
|
const ut = node.getComponent(UITransform);
|
|
const halfDiag = ut ? Math.hypot(ut.width, ut.height) / 2 : 40;
|
|
const radius = halfDiag * (Math.abs(worldScale.x) || 1);
|
|
flyLayer.active = true;
|
|
node.parent = flyLayer;
|
|
node.setWorldScale(worldScale);
|
|
node.setPosition(flyUt.convertToNodeSpaceAR(from));
|
|
const fly: ExtraRingFly = { node, run, radius, age: 0, consumed: false };
|
|
this._flies.push(fly);
|
|
SkillTransformDriver.animate({
|
|
node,
|
|
duration: Math.max(0.05, run.flyDist / run.speed),
|
|
fromPos: node.position.clone(),
|
|
toPos: flyUt.convertToNodeSpaceAR(to),
|
|
easing: SkillTransformDriver.easeLinear,
|
|
onComplete: () => this._finishFly(fly),
|
|
});
|
|
}
|
|
|
|
/** 从对象池取模板克隆一支并播 Spine */
|
|
private _spawnClone(deg: number): Node | null {
|
|
let tpl: Node | null = null;
|
|
try {
|
|
tpl = gg.res.getNode(GPath.HeroSkillPrefab(), RING_PREFAB, GBundle.BattleHero);
|
|
} catch (e) {
|
|
console.log('[ExtraRing] 龙影预制体加载异常', String(e));
|
|
return null;
|
|
}
|
|
if (!tpl?.isValid) {
|
|
console.log('[ExtraRing] 龙影预制体缺失', RING_PREFAB);
|
|
return null;
|
|
}
|
|
const clone = instantiate(tpl);
|
|
gg.res.putNode(tpl);
|
|
clone.active = true;
|
|
clone.angle = deg;
|
|
const skel = clone.getComponentInChildren(sp.Skeleton);
|
|
if (skel?.isValid) {
|
|
skel.timeScale = 1;
|
|
const typed = skel as sp.Skeleton & { defaultAnimation?: string };
|
|
skel.setAnimation(0, String(typed.defaultAnimation || RING_ANIM), true);
|
|
}
|
|
return clone;
|
|
}
|
|
|
|
/** 回收一支:停飞、销毁 */
|
|
private _finishFly(fly: ExtraRingFly): void {
|
|
const idx = this._flies.indexOf(fly);
|
|
if (idx < 0) return;
|
|
this._flies.splice(idx, 1);
|
|
if (fly.node?.isValid) {
|
|
SkillTransformDriver.cancelNode(fly.node);
|
|
fly.node.destroy();
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Skill.skill type=10 表现入口 */
|
|
export class RingProjectilesPresenter implements ISkillPresenter {
|
|
/**
|
|
* 播放环形龙影。
|
|
* @param ctx 表现上下文
|
|
* @param host Skill 宿主
|
|
*/
|
|
present(ctx: SkillPresentationContext, host: Skill): void {
|
|
if (!ctx.isAlive) {
|
|
ctx.finish();
|
|
return;
|
|
}
|
|
getExtraRingHost().start(ctx, host);
|
|
}
|
|
}
|
|
|