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/RingProjectilesPresenter.ts

329 lines
12 KiB

2 weeks ago
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,需显式播放) */
1 week ago
const RING_ANIM = '剑气金';
2 weeks ago
/** 出生外扩兜底(像素):施法者攻击范围取不到时用 */
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;
1 week ago
const typed = skel as sp.Skeleton & { defaultAnimation?: string };
skel.setAnimation(0, String(typed.defaultAnimation || RING_ANIM), true);
2 weeks ago
}
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);
}
}