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/skill/RingFlyPresenter.ts

645 lines
20 KiB

3 weeks ago
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'));
}