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.
644 lines
20 KiB
644 lines
20 KiB
import {
|
|
director,
|
|
Director,
|
|
game,
|
|
instantiate,
|
|
Node,
|
|
sp,
|
|
tween,
|
|
Tween,
|
|
UIOpacity,
|
|
UITransform,
|
|
Vec3,
|
|
} from 'cc';
|
|
import type { EntityId } from 'db://assets/mx/module/ecs';
|
|
import { BattleMapSpec } from '../map/BattleMapSpec';
|
|
import { parseAxePiercePx } from '../view/AxeFlyPresenter';
|
|
import { SkillCollisionHit, type SkillCollisionPiercePolicy } from './SkillCollisionHit';
|
|
import { SkillPresentationEnv } from './SkillPresentationEnv';
|
|
import type { SkillPresentationContext } from './SkillPresentationTypes';
|
|
import { requireNonNegativePart, requirePositivePart } from './BaseAtkParamReader';
|
|
import { SkillTransformDriver } from './SkillTransformDriver';
|
|
|
|
const RING_ATTACK_SPEED = 640;
|
|
const RING_ULT_SPEED = 720;
|
|
const RING_VISUAL_NAME = '乾坤圈攻击';
|
|
const TRAIL_NODE_NAME = '乾坤圈拖尾';
|
|
const TRAIL_FADE_IN_SEC = 0.22;
|
|
const MAP_EDGE_INSET_PX = 48;
|
|
const BOOMERANG_EDGE_INSET_PX = 16;
|
|
|
|
function _resolveBattleMapWorldBounds(insetPx: number): {
|
|
minX: number;
|
|
minY: number;
|
|
maxX: number;
|
|
maxY: number;
|
|
} {
|
|
const half = BattleMapSpec.CELL_SIZE * 0.5;
|
|
const inset = Math.max(0, insetPx);
|
|
const map = SkillPresentationEnv.getMap();
|
|
if (map?.gridsInitialized) {
|
|
const corners: Vec3[] = [];
|
|
const rows = BattleMapSpec.ROWS;
|
|
const cols = BattleMapSpec.COLS;
|
|
for (const [row, col] of [
|
|
[0, 0],
|
|
[0, cols - 1],
|
|
[rows - 1, 0],
|
|
[rows - 1, cols - 1],
|
|
] as const) {
|
|
const mg = map.getMapGrid(row, col);
|
|
if (mg?.node?.isValid) corners.push(mg.node.worldPosition.clone());
|
|
}
|
|
if (corners.length > 0) {
|
|
let minX = Infinity;
|
|
let minY = Infinity;
|
|
let maxX = -Infinity;
|
|
let maxY = -Infinity;
|
|
for (const p of corners) {
|
|
minX = Math.min(minX, p.x - half);
|
|
maxX = Math.max(maxX, p.x + half);
|
|
minY = Math.min(minY, p.y - half);
|
|
maxY = Math.max(maxY, p.y + half);
|
|
}
|
|
return {
|
|
minX: minX + inset,
|
|
minY: minY + inset,
|
|
maxX: maxX - inset,
|
|
maxY: maxY - inset,
|
|
};
|
|
}
|
|
}
|
|
const mapW = BattleMapSpec.COLS * BattleMapSpec.CELL_SIZE;
|
|
const mapH = BattleMapSpec.ROWS * BattleMapSpec.CELL_SIZE;
|
|
return {
|
|
minX: inset,
|
|
minY: inset,
|
|
maxX: mapW - inset,
|
|
maxY: mapH - inset,
|
|
};
|
|
}
|
|
|
|
function _estimateRingRadiusPx(node: Node): number {
|
|
const ut = node.getComponent(UITransform) ?? node.getComponentInChildren(UITransform);
|
|
if (!ut) return 40;
|
|
const ws = node.worldScale;
|
|
return Math.max(ut.contentSize.width * Math.abs(ws.x), ut.contentSize.height * Math.abs(ws.y)) * 0.5;
|
|
}
|
|
|
|
export interface NezhaLineSpecials {
|
|
extraShots: number;
|
|
spreadDeg: number;
|
|
growSizeWan: number;
|
|
growDmgWan: number;
|
|
boomerang: boolean;
|
|
}
|
|
|
|
export interface RingFlyHandle {
|
|
node: Node;
|
|
hit: SkillCollisionHit | null;
|
|
dispose(): void;
|
|
}
|
|
|
|
export function parseNezhaLineSpecials(specials: readonly string[]): NezhaLineSpecials {
|
|
const out: NezhaLineSpecials = {
|
|
extraShots: 0,
|
|
spreadDeg: 0,
|
|
growSizeWan: 0,
|
|
growDmgWan: 0,
|
|
boomerang: false,
|
|
};
|
|
for (const raw of specials) {
|
|
const parts = String(raw).split(',').map((s) => Number(s.trim()));
|
|
const head = parts[0] ?? 0;
|
|
if (head === 3) {
|
|
const spreadDeg = requirePositivePart(parts, 1);
|
|
const extraShotsRaw = requireNonNegativePart(parts, 2);
|
|
if (spreadDeg == null || extraShotsRaw == null) continue;
|
|
out.spreadDeg = spreadDeg;
|
|
out.extraShots = Math.floor(extraShotsRaw);
|
|
} else if (head === 4) {
|
|
const growSizeWan = requireNonNegativePart(parts, 1);
|
|
const growDmgWan = requireNonNegativePart(parts, 2);
|
|
if (growSizeWan == null || growDmgWan == null) continue;
|
|
out.growSizeWan = Math.max(out.growSizeWan, growSizeWan);
|
|
out.growDmgWan = Math.max(out.growDmgWan, growDmgWan);
|
|
} else if (head === 5) {
|
|
out.boomerang = true;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** 从施法者已选大招词条解析哪吒直线技能 special(1055 变大 / 1056 回旋,up_type 2) */
|
|
export function resolveNezhaLineSpecials(caster: EntityId): NezhaLineSpecials {
|
|
return parseNezhaLineSpecials(SkillPresentationEnv.getUltSkillSpecials(caster));
|
|
}
|
|
|
|
function _battleDeltaSec(): number {
|
|
const battle = gg.game?.CurentBattle as { TimeScale?: number } | null | undefined;
|
|
const scale = battle?.TimeScale ?? 1;
|
|
return (game.deltaTime || 1 / 60) * scale;
|
|
}
|
|
|
|
export function resolveZhaSkillTemplate(skin: Node | null): Node | null {
|
|
if (!skin?.isValid) return null;
|
|
const anim = skin.find('anim') ?? skin;
|
|
return anim.getChildByName('skillTemplate')
|
|
?? anim.getChildByName('skillTamplate')
|
|
?? null;
|
|
}
|
|
|
|
export function resolveRingTemplateOnSkin(skin: Node | null): Node | null {
|
|
if (!skin?.isValid) return null;
|
|
const anim = skin.find('anim') ?? skin;
|
|
return anim.getChildByName('atkTemplate')
|
|
?? anim.getChildByName('atkTamplate')
|
|
?? resolveZhaSkillTemplate(skin);
|
|
}
|
|
|
|
export function buildSpreadAngles(mainDeg: number, extraShots: number, spreadDeg: number): number[] {
|
|
const total = 1 + Math.max(0, extraShots);
|
|
if (total <= 1) return [mainDeg];
|
|
const angles: number[] = [];
|
|
const half = spreadDeg * 0.5;
|
|
for (let i = 0; i < total; i++) {
|
|
const t = total === 1 ? 0 : (i / (total - 1)) - 0.5;
|
|
angles.push(mainDeg + t * spreadDeg * 2);
|
|
}
|
|
if (total === 3 && extraShots === 2) {
|
|
return [mainDeg - half, mainDeg, mainDeg + half];
|
|
}
|
|
return angles;
|
|
}
|
|
|
|
function _findTrail(projectile: Node): Node | null {
|
|
return projectile.getChildByName(TRAIL_NODE_NAME)
|
|
?? projectile.find(TRAIL_NODE_NAME);
|
|
}
|
|
|
|
function _beginTrailFadeIn(projectile: Node): void {
|
|
const trail = _findTrail(projectile);
|
|
if (!trail?.isValid) return;
|
|
trail.active = true;
|
|
const uo = trail.getComponent(UIOpacity) ?? trail.addComponent(UIOpacity);
|
|
Tween.stopAllByTarget(uo);
|
|
uo.opacity = 0;
|
|
tween(uo)
|
|
.to(TRAIL_FADE_IN_SEC, { opacity: 255 }, { easing: 'sineOut' })
|
|
.start();
|
|
}
|
|
|
|
function _playRingSpine(projectile: Node): void {
|
|
const ring = projectile.getChildByName(RING_VISUAL_NAME)
|
|
?? projectile.find(RING_VISUAL_NAME);
|
|
const anim = ring?.getComponent(sp.Skeleton);
|
|
if (!anim?.skeletonData) return;
|
|
const data = anim.skeletonData.getRuntimeData?.();
|
|
const flyName = data?.findAnimation?.('飞行') ? '飞行'
|
|
: (data?.findAnimation?.('攻击') ? '攻击' : null);
|
|
if (!flyName) return;
|
|
anim.clearTracks();
|
|
anim.setAnimation(0, flyName, true);
|
|
}
|
|
|
|
function _cloneToFlyLayer(
|
|
template: Node,
|
|
flyLayer: Node,
|
|
flyUt: UITransform,
|
|
anchorWorld: Readonly<Vec3>,
|
|
): Node | null {
|
|
if (!template?.isValid) return null;
|
|
const clone = instantiate(template);
|
|
clone.active = true;
|
|
const worldScale = template.worldScale.clone();
|
|
clone.parent = flyLayer;
|
|
clone.setWorldScale(worldScale);
|
|
clone.setPosition(flyUt.convertToNodeSpaceAR(anchorWorld));
|
|
return clone;
|
|
}
|
|
|
|
function _angleDeg(from: Readonly<Vec3>, to: Readonly<Vec3>): number {
|
|
return Math.atan2(to.y - from.y, to.x - from.x) * 180 / Math.PI;
|
|
}
|
|
|
|
/** 地图对角线跨度(射线与边界无交点时的兜底飞行距离) */
|
|
function _mapDiagonalSpanPx(insetPx: number): number {
|
|
const bounds = _resolveBattleMapWorldBounds(insetPx);
|
|
return Math.hypot(bounds.maxX - bounds.minX, bounds.maxY - bounds.minY);
|
|
}
|
|
|
|
/** 沿角度从起点射线与地图可视矩形边界的交点(乾坤圈须完全留在屏内) */
|
|
export function computeRayToMapEdge(
|
|
startWorld: Readonly<Vec3>,
|
|
angleDeg: number,
|
|
insetPx: number,
|
|
ringRadiusPx = 0,
|
|
): Vec3 {
|
|
const inset = insetPx + Math.max(0, ringRadiusPx);
|
|
const bounds = _resolveBattleMapWorldBounds(inset);
|
|
const rad = angleDeg * Math.PI / 180;
|
|
const dx = Math.cos(rad);
|
|
const dy = Math.sin(rad);
|
|
|
|
let tEnter = -Infinity;
|
|
let tExit = Infinity;
|
|
|
|
if (Math.abs(dx) < 1e-6) {
|
|
if (startWorld.x < bounds.minX || startWorld.x > bounds.maxX) {
|
|
tEnter = Infinity;
|
|
}
|
|
} else {
|
|
const tx1 = (bounds.minX - startWorld.x) / dx;
|
|
const tx2 = (bounds.maxX - startWorld.x) / dx;
|
|
tEnter = Math.max(tEnter, Math.min(tx1, tx2));
|
|
tExit = Math.min(tExit, Math.max(tx1, tx2));
|
|
}
|
|
|
|
if (Math.abs(dy) < 1e-6) {
|
|
if (startWorld.y < bounds.minY || startWorld.y > bounds.maxY) {
|
|
tEnter = Infinity;
|
|
}
|
|
} else {
|
|
const ty1 = (bounds.minY - startWorld.y) / dy;
|
|
const ty2 = (bounds.maxY - startWorld.y) / dy;
|
|
tEnter = Math.max(tEnter, Math.min(ty1, ty2));
|
|
tExit = Math.min(tExit, Math.max(ty1, ty2));
|
|
}
|
|
|
|
let tMin = tEnter;
|
|
if (!Number.isFinite(tMin) || tMin <= 0 || tExit < tEnter) {
|
|
// 起点在边界外或射线朝外:退回整图对角线,避免只飞 120px 在怪附近消失
|
|
tMin = Math.max(120, _mapDiagonalSpanPx(inset));
|
|
}
|
|
|
|
return new Vec3(
|
|
startWorld.x + dx * tMin,
|
|
startWorld.y + dy * tMin,
|
|
startWorld.z,
|
|
);
|
|
}
|
|
|
|
function _bindPierce(
|
|
node: Node,
|
|
ctx: SkillPresentationContext,
|
|
policy: SkillCollisionPiercePolicy,
|
|
): SkillCollisionHit | null {
|
|
if (!node?.isValid || !ctx.isAlive) return null;
|
|
return new SkillCollisionHit().bind(node, ctx, policy);
|
|
}
|
|
|
|
function _destroyProjectile(node: Node, hit: SkillCollisionHit | null): void {
|
|
hit?.dispose();
|
|
if (!node?.isValid) return;
|
|
const trail = _findTrail(node);
|
|
const trailUo = trail?.getComponent(UIOpacity);
|
|
if (trailUo) Tween.stopAllByTarget(trailUo);
|
|
SkillTransformDriver.cancelNode(node);
|
|
node.destroy();
|
|
}
|
|
|
|
function _maybeHideFlyLayer(flyLayer: Node | null): void {
|
|
if (flyLayer?.isValid && flyLayer.children.length === 0) {
|
|
flyLayer.active = false;
|
|
}
|
|
}
|
|
|
|
interface GrowState {
|
|
elapsed: number;
|
|
root: Node;
|
|
/** 克隆到 flyLayer 后、叠体积前的世界缩放(与 setWorldScale 递增一致) */
|
|
baseWorldScale: Vec3;
|
|
/** 出手瞬间体积倍率(含 base_atk 2.5 等) */
|
|
launchSizeMul: number;
|
|
/** 出手瞬间伤害倍率(含 base_atk 6 等) */
|
|
launchDamageScale: number;
|
|
policy: SkillCollisionPiercePolicy;
|
|
}
|
|
|
|
function _applyProjectileGrowSize(state: GrowState, totalMul: number): void {
|
|
if (state.root?.isValid) {
|
|
const bs = state.baseWorldScale;
|
|
state.root.setWorldScale(
|
|
bs.x * totalMul,
|
|
bs.y * totalMul,
|
|
bs.z,
|
|
);
|
|
}
|
|
}
|
|
|
|
interface ActiveVolumeGrow {
|
|
node: Node;
|
|
state: GrowState;
|
|
growSizeWan: number;
|
|
growDmgWan: number;
|
|
}
|
|
|
|
const _activeVolumeGrow: ActiveVolumeGrow[] = [];
|
|
let _volumeGrowHooked = false;
|
|
|
|
function _volumeGrowAfterUpdate(): void {
|
|
if (_activeVolumeGrow.length === 0) return;
|
|
const dt = _battleDeltaSec();
|
|
for (let i = _activeVolumeGrow.length - 1; i >= 0; i--) {
|
|
const entry = _activeVolumeGrow[i];
|
|
const { node, state, growSizeWan, growDmgWan } = entry;
|
|
if (!node?.isValid) {
|
|
_activeVolumeGrow.splice(i, 1);
|
|
continue;
|
|
}
|
|
state.elapsed += dt;
|
|
const sec = Math.max(0, state.elapsed);
|
|
// 1055:每秒在「发射时」体积/伤害上叠万分比,不是按 1 倍基数叠
|
|
const sizeRate = growSizeWan / 10000;
|
|
const dmgRate = growDmgWan / 10000;
|
|
const totalSizeMul = state.launchSizeMul * (1 + sizeRate * sec);
|
|
const totalDmgScale = state.launchDamageScale * (1 + dmgRate * sec);
|
|
_applyProjectileGrowSize(state, totalSizeMul);
|
|
state.policy.scale = totalDmgScale;
|
|
}
|
|
}
|
|
|
|
function _ensureVolumeGrowHook(): void {
|
|
if (_volumeGrowHooked) return;
|
|
_volumeGrowHooked = true;
|
|
director.on(Director.EVENT_AFTER_UPDATE, _volumeGrowAfterUpdate);
|
|
}
|
|
|
|
function _attachVolumeAndGrowing(
|
|
node: Node,
|
|
baseVolumeScale: number,
|
|
growSizeWan: number,
|
|
growDmgWan: number,
|
|
policy: GrowState['policy'],
|
|
baseDamageScale: number,
|
|
): () => void {
|
|
const launchSizeMul = Math.max(0.2, baseVolumeScale);
|
|
const launchDamageScale = baseDamageScale;
|
|
const hasGrow = growSizeWan > 0 || growDmgWan > 0;
|
|
if (launchSizeMul === 1 && !hasGrow) return () => {};
|
|
|
|
const state: GrowState = {
|
|
elapsed: 0,
|
|
root: node,
|
|
baseWorldScale: node.worldScale.clone(),
|
|
launchSizeMul,
|
|
launchDamageScale,
|
|
policy,
|
|
};
|
|
const entry: ActiveVolumeGrow = { node, state, growSizeWan, growDmgWan };
|
|
_ensureVolumeGrowHook();
|
|
_activeVolumeGrow.push(entry);
|
|
_applyProjectileGrowSize(state, launchSizeMul);
|
|
state.policy.scale = launchDamageScale;
|
|
|
|
return () => {
|
|
const idx = _activeVolumeGrow.indexOf(entry);
|
|
if (idx >= 0) _activeVolumeGrow.splice(idx, 1);
|
|
};
|
|
}
|
|
|
|
/** 普攻:全程穿透碰撞伤害(对齐弓/箭,出手后 finish,伤害仅 dealDamage) */
|
|
export function launchRingAttackFlight(opts: {
|
|
ctx: SkillPresentationContext;
|
|
template: Node;
|
|
anchorWorld: Readonly<Vec3>;
|
|
targetWorld: Readonly<Vec3>;
|
|
piercePx: number;
|
|
flySpeed?: number;
|
|
onComplete: () => void;
|
|
}): RingFlyHandle | null {
|
|
const {
|
|
ctx, template, anchorWorld, targetWorld,
|
|
piercePx, onComplete,
|
|
} = opts;
|
|
const flySpeed = opts.flySpeed ?? RING_ATTACK_SPEED;
|
|
if (!ctx.isAlive) {
|
|
onComplete();
|
|
return null;
|
|
}
|
|
|
|
const flyLayer = SkillPresentationEnv.flyLayer;
|
|
const flyUt = flyLayer?.getComponent(UITransform);
|
|
if (!flyLayer?.isValid || !flyUt) {
|
|
onComplete();
|
|
return null;
|
|
}
|
|
|
|
flyLayer.active = true;
|
|
const projectile = _cloneToFlyLayer(template, flyLayer, flyUt, anchorWorld);
|
|
if (!projectile) {
|
|
onComplete();
|
|
return null;
|
|
}
|
|
|
|
const flyDeg = _angleDeg(anchorWorld, targetWorld);
|
|
const rad = flyDeg * Math.PI / 180;
|
|
const pierceEndWorld = piercePx > 0
|
|
? new Vec3(
|
|
targetWorld.x + Math.cos(rad) * piercePx,
|
|
targetWorld.y + Math.sin(rad) * piercePx,
|
|
targetWorld.z,
|
|
)
|
|
: targetWorld;
|
|
const flyEndWorld = piercePx > 0 ? pierceEndWorld : targetWorld;
|
|
|
|
const startLocal = projectile.position.clone();
|
|
const endLocal = flyUt.convertToNodeSpaceAR(flyEndWorld);
|
|
projectile.angle = flyDeg;
|
|
_beginTrailFadeIn(projectile);
|
|
_playRingSpine(projectile);
|
|
|
|
const atkPolicy = { mode: 'pierce' as const, scale: 1 };
|
|
let pathHit = _bindPierce(projectile, ctx, atkPolicy);
|
|
let disposed = false;
|
|
const dispose = () => {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
_destroyProjectile(projectile, pathHit);
|
|
pathHit = null;
|
|
_maybeHideFlyLayer(flyLayer);
|
|
};
|
|
|
|
const flyDist = Math.max(1, Vec3.distance(anchorWorld, flyEndWorld));
|
|
const duration = Math.max(0.08, flyDist / flySpeed);
|
|
|
|
let flyCompleted = false;
|
|
const notifyFlyComplete = () => {
|
|
if (flyCompleted) return;
|
|
flyCompleted = true;
|
|
onComplete();
|
|
};
|
|
const finishFly = () => {
|
|
dispose();
|
|
notifyFlyComplete();
|
|
};
|
|
|
|
SkillTransformDriver.animate({
|
|
node: projectile,
|
|
duration,
|
|
fromPos: startLocal,
|
|
toPos: endLocal,
|
|
easing: SkillTransformDriver.easeLinear,
|
|
onComplete: finishFly,
|
|
});
|
|
|
|
return {
|
|
node: projectile,
|
|
get hit() { return pathHit; },
|
|
dispose: finishFly,
|
|
};
|
|
}
|
|
|
|
/** 大招:直线无限碰撞 / 回旋 */
|
|
export function launchRingUltFlight(opts: {
|
|
ctx: SkillPresentationContext;
|
|
template: Node;
|
|
anchorWorld: Readonly<Vec3>;
|
|
aimWorld: Readonly<Vec3>;
|
|
damageScale: number;
|
|
volumeScale: number;
|
|
specials: NezhaLineSpecials;
|
|
angleDeg?: number;
|
|
onComplete: () => void;
|
|
}): RingFlyHandle | null {
|
|
const {
|
|
ctx, template, anchorWorld, aimWorld, damageScale, volumeScale, specials, onComplete,
|
|
} = opts;
|
|
const angleDeg = opts.angleDeg ?? _angleDeg(anchorWorld, aimWorld);
|
|
if (!ctx.isAlive) {
|
|
onComplete();
|
|
return null;
|
|
}
|
|
|
|
const flyLayer = SkillPresentationEnv.flyLayer;
|
|
const flyUt = flyLayer?.getComponent(UITransform);
|
|
if (!flyLayer?.isValid || !flyUt) {
|
|
onComplete();
|
|
return null;
|
|
}
|
|
|
|
flyLayer.active = true;
|
|
const projectile = _cloneToFlyLayer(template, flyLayer, flyUt, anchorWorld);
|
|
if (!projectile) {
|
|
onComplete();
|
|
return null;
|
|
}
|
|
|
|
const startLocal = projectile.position.clone();
|
|
projectile.angle = angleDeg;
|
|
_beginTrailFadeIn(projectile);
|
|
_playRingSpine(projectile);
|
|
|
|
const policy = {
|
|
mode: 'pierce' as const,
|
|
scale: damageScale,
|
|
damageType: 'nezha_ult',
|
|
};
|
|
const stopGrow = _attachVolumeAndGrowing(
|
|
projectile,
|
|
volumeScale,
|
|
specials.growSizeWan,
|
|
specials.growDmgWan,
|
|
policy,
|
|
damageScale,
|
|
);
|
|
|
|
const startWorld = projectile.worldPosition.clone();
|
|
const ringRadius = _estimateRingRadiusPx(projectile);
|
|
const inset = specials.boomerang ? BOOMERANG_EDGE_INSET_PX : MAP_EDGE_INSET_PX;
|
|
const edgeWorld = computeRayToMapEdge(startWorld, angleDeg, inset, ringRadius);
|
|
const edgeLocal = flyUt.convertToNodeSpaceAR(edgeWorld);
|
|
|
|
let pathHit = _bindPierce(projectile, ctx, policy);
|
|
|
|
let disposed = false;
|
|
const dispose = () => {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
stopGrow();
|
|
_destroyProjectile(projectile, pathHit);
|
|
pathHit = null;
|
|
_maybeHideFlyLayer(flyLayer);
|
|
};
|
|
|
|
const outboundDist = Math.max(1, Vec3.distance(startLocal, edgeLocal));
|
|
const outboundDur = Math.max(0.08, outboundDist / RING_ULT_SPEED);
|
|
|
|
let flyCompleted = false;
|
|
const notifyFlyComplete = () => {
|
|
if (flyCompleted) return;
|
|
flyCompleted = true;
|
|
onComplete();
|
|
};
|
|
|
|
const finishAll = () => {
|
|
dispose();
|
|
notifyFlyComplete();
|
|
};
|
|
|
|
const flySegment = (
|
|
from: Readonly<Vec3>,
|
|
to: Readonly<Vec3>,
|
|
onDone: () => void,
|
|
segmentAngleDeg?: number,
|
|
) => {
|
|
// 每段重新 bind,清空命中集 → 回旋去/回各可对同怪各伤一次
|
|
pathHit?.dispose();
|
|
pathHit = _bindPierce(projectile, ctx, policy);
|
|
projectile.angle = segmentAngleDeg ?? _angleDeg(from, to);
|
|
const segDist = Math.max(1, Vec3.distance(from, to));
|
|
const segDur = Math.max(0.08, segDist / RING_ULT_SPEED);
|
|
SkillTransformDriver.animate({
|
|
node: projectile,
|
|
duration: segDur,
|
|
fromPos: from.clone(),
|
|
toPos: to.clone(),
|
|
easing: SkillTransformDriver.easeLinear,
|
|
onComplete: onDone,
|
|
});
|
|
};
|
|
|
|
if (!specials.boomerang) {
|
|
flySegment(startLocal, edgeLocal, finishAll, angleDeg);
|
|
} else {
|
|
const returnDeg = angleDeg + 180;
|
|
flySegment(startLocal, edgeLocal, () => {
|
|
if (disposed || !ctx.isAlive) {
|
|
finishAll();
|
|
return;
|
|
}
|
|
flySegment(edgeLocal, startLocal, finishAll, returnDeg);
|
|
}, angleDeg);
|
|
}
|
|
|
|
return {
|
|
node: projectile,
|
|
get hit() { return pathHit; },
|
|
dispose: finishAll,
|
|
};
|
|
}
|
|
|
|
export function parseNezhaUltParams(params: Readonly<Record<string, unknown>>): {
|
|
damageScale: number;
|
|
volumeScale: number;
|
|
} | null {
|
|
const parts = Array.isArray(params.skillParts)
|
|
? (params.skillParts as number[])
|
|
: String(params.skillRaw ?? '').split(',').map(Number);
|
|
const skillType = parts[0] ?? 0;
|
|
if (skillType !== 4 || parts.length < 2) return null;
|
|
const damageScale = requirePositivePart(parts, 1);
|
|
if (damageScale == null) return null;
|
|
const volumeScale = parts.length >= 3 ? requirePositivePart(parts, 2) : 1;
|
|
if (volumeScale == null) return null;
|
|
return { damageScale, volumeScale };
|
|
}
|
|
|
|
export function parseNezhaAttackPiercePx(
|
|
ctx: SkillPresentationContext,
|
|
params: Readonly<Record<string, unknown>>,
|
|
): number {
|
|
return parseAxePiercePx(params)
|
|
+ Math.max(0, SkillPresentationEnv.getCasterAttr(ctx.caster, 'para_4'));
|
|
}
|
|
|