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.
785 lines
27 KiB
785 lines
27 KiB
import { Node, Vec3, sp } from 'cc';
|
|
import type { BattleSession, Cell, EntityId } from 'db://assets/mx/module/battle';
|
|
import { BattleTeamId } 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 { BattleMapSpec } from '../map/BattleMapSpec';
|
|
import type { MapManager } from '../map/legacy/MapManager';
|
|
import { HeroActorHost } from '../heros/HeroActorHost';
|
|
import { HeroSkin } from '../view/HeroSkin';
|
|
import type { Skill } from '../skill/Skill';
|
|
import type { SkillPresentationContext } from '../skill/SkillPresentationTypes';
|
|
import { SkillPresentationEnv } from '../skill/SkillPresentationEnv';
|
|
import {
|
|
computeAimAngleDeg,
|
|
setNodeWorldPos,
|
|
shortestAngleDeltaDeg,
|
|
} from '../skill/SkillTransformDriver';
|
|
import { ensureSpineSkeletonDataRuntime } from 'db://assets/mx/module/lang/spineRuntimePatch';
|
|
import { applyWeaponSpineSkin } from '../view/GlyphSpineSkin';
|
|
import { BattleSpinePlayback } from './BattleSpinePlayback';
|
|
import { getEarthChargeHost } from './EarthChargePresenter';
|
|
import { isEarthChargeSkillId } from '../config/weapon1/Weapon1BattleIds';
|
|
import type { ISkillPresenter } from './SkillPresenter';
|
|
|
|
/** battleHero/prefab/skill/哪吒冲锋 */
|
|
const CHARGE_PREFAB = '哪吒冲锋';
|
|
/** 预制体枪节点(默认动画 + 朝冲锋方向转角) */
|
|
const SPEAR_NODE = '冲锋枪';
|
|
/** 枪头转向速度(度/秒) */
|
|
const SPEAR_TURN_DEG = 540;
|
|
/** 沿路径前瞻(像素),用于算朝向 */
|
|
const SPEAR_LOOKAHEAD = 28;
|
|
/** 预制体哪吒身体 */
|
|
const BODY_NODE = '默认';
|
|
/** 组合字:飞出冲锋的字 */
|
|
const ZHA_NAME = '吒';
|
|
/** 冲锋期间英雄待机(只留哪) */
|
|
const IDLE_SINGLE = '待机单字';
|
|
/** 冲锋结束回普通待机 */
|
|
const IDLE_ANIM = '待机';
|
|
/** 英雄预制体阴影根 */
|
|
const SHADOW_ROOT = '阴影';
|
|
/** 冲锋期间须隐藏的阴影子节点(吒脚下影;按名取,勿用 children 下标) */
|
|
const SHADOW_SECOND = 'ebg-001';
|
|
/** 起跳蓄力(秒) */
|
|
const JUMP_ANTICIP = 0.08;
|
|
/** 滞空(秒) */
|
|
const JUMP_AIR = 0.3;
|
|
/** 落地回正(秒) */
|
|
const JUMP_SETTLE = 0.07;
|
|
/** 跳出总时长 */
|
|
const JUMP_TOTAL = JUMP_ANTICIP + JUMP_AIR + JUMP_SETTLE;
|
|
/** 跳弧高度(像素) */
|
|
const JUMP_H = 96;
|
|
/** 沿途命中半径(像素,对齐冲锋枪半长) */
|
|
const HIT_RADIUS = 100;
|
|
/** 冲锋直伤类型 */
|
|
const DMG_TYPE = 'nezha_charge';
|
|
|
|
/** Skill.skill type=1 解析结果 */
|
|
export interface NezhaChargeParams {
|
|
/** 冲锋速度(像素/秒) */
|
|
speed: number;
|
|
/** 持续秒 */
|
|
duration: number;
|
|
/** 伤害万分比 → DealDamage scale */
|
|
scale: number;
|
|
}
|
|
|
|
/** 一次冲锋运行态 */
|
|
interface NezhaChargeRun {
|
|
/** 施法者 */
|
|
caster: EntityId;
|
|
/** 表现上下文 */
|
|
ctx: SkillPresentationContext;
|
|
/** Skill 宿主 */
|
|
host: Skill;
|
|
/** 播控序号 */
|
|
seq: number;
|
|
/** 冲锋根节点 */
|
|
node: Node;
|
|
/** 冲锋枪节点 */
|
|
spear: Node;
|
|
/** 路径世界坐标(含吸附点) */
|
|
waypoints: Vec3[];
|
|
/** 像素/秒 */
|
|
speed: number;
|
|
/** 持续秒 */
|
|
duration: number;
|
|
/** 伤害系数 */
|
|
scale: number;
|
|
/** 冲锋已过秒(不含跳出) */
|
|
elapsed: number;
|
|
/** 跳出已过秒 */
|
|
jumpElapsed: number;
|
|
/** 吒字世界 X */
|
|
jumpFromX: number;
|
|
/** 吒字世界 Y */
|
|
jumpFromY: number;
|
|
/** 吸附格世界 X */
|
|
jumpToX: number;
|
|
/** 吸附格世界 Y */
|
|
jumpToY: number;
|
|
/** 已走路程(像素) */
|
|
traveled: number;
|
|
/** 枪当前角(度,0=右) */
|
|
spearAngle: number;
|
|
/** 已命中实体 */
|
|
hitIds: EntityId[];
|
|
/** 英雄根(结束回待机) */
|
|
heroNode: Node | null;
|
|
/** 阴影第二节点(吒影,结束恢复) */
|
|
shadow2: Node | null;
|
|
/** 上一帧世界坐标 */
|
|
lastX: number;
|
|
/** 上一帧世界坐标 */
|
|
lastY: number;
|
|
}
|
|
|
|
let _host: NezhaChargeHost | null = null;
|
|
|
|
/** 哪吒冲锋运行时(表现+沿途伤) */
|
|
export function getNezhaChargeHost(): NezhaChargeHost {
|
|
if (!_host) _host = new NezhaChargeHost();
|
|
return _host;
|
|
}
|
|
|
|
/** 哪吒冲锋:英雄待机单字、吒位跳出、路径反冲、沿途伤 */
|
|
export class NezhaChargeHost {
|
|
private readonly _runs: NezhaChargeRun[] = [];
|
|
private readonly _tmp = new Vec3();
|
|
private readonly _aim = new Vec3();
|
|
private readonly _from = new Vec3();
|
|
/** 当前命中扫描的冲锋 */
|
|
private _hitRun: NezhaChargeRun | null = null;
|
|
|
|
/**
|
|
* 解析 `1,速度,时长,伤害万分比`。
|
|
* @param raw Skill.skill
|
|
*/
|
|
static parse(raw: string): NezhaChargeParams | null {
|
|
const parts = String(raw ?? '').split(',').map((s) => Number(String(s).trim()));
|
|
if (parts[0] !== 1) return null;
|
|
const speed = parts[1];
|
|
const duration = parts[2];
|
|
const wan = parts[3];
|
|
if (!(speed > 0) || !(duration > 0) || !(wan > 0)) {
|
|
console.log('[NezhaCharge] skill 参数无效');
|
|
return null;
|
|
}
|
|
return { speed, duration, scale: wan / 10000 };
|
|
}
|
|
|
|
/**
|
|
* 开战冲锋。
|
|
* @param ctx 表现上下文
|
|
* @param host Skill 宿主
|
|
*/
|
|
start(ctx: SkillPresentationContext, host: Skill): void {
|
|
this._abortCaster(ctx.caster);
|
|
const raw = String(ctx.params.extraSkill ?? ctx.params.skillRaw ?? '');
|
|
const params = NezhaChargeHost.parse(raw);
|
|
if (!params) {
|
|
host.drainLogicPresentation(ctx);
|
|
return;
|
|
}
|
|
const session = SkillPresentationEnv.getBattleSession();
|
|
const map = SkillPresentationEnv.getMap();
|
|
if (!session || !map) {
|
|
console.log('[NezhaCharge] 无 session/map');
|
|
host.drainLogicPresentation(ctx);
|
|
return;
|
|
}
|
|
|
|
const zhaIdx = SkillPresentationEnv.getCompositionGridIndexByWeapon2Name(ctx.caster, ZHA_NAME);
|
|
const zhaWorld = zhaIdx != null
|
|
? SkillPresentationEnv.getGridWorldCenter(zhaIdx)
|
|
: SkillPresentationEnv.getCasterGridCenter(ctx.caster);
|
|
if (!zhaWorld) {
|
|
console.log('[NezhaCharge] 无吒字落点', ctx.caster);
|
|
host.drainLogicPresentation(ctx);
|
|
return;
|
|
}
|
|
|
|
const fromCell = this._zhaCell(zhaIdx, zhaWorld, map);
|
|
if (!fromCell) {
|
|
console.log('[NezhaCharge] 吒字格无效');
|
|
host.drainLogicPresentation(ctx);
|
|
return;
|
|
}
|
|
const snap = this._snapApproachCell(session, ctx.caster, fromCell);
|
|
if (!snap) {
|
|
console.log('[NezhaCharge] 进路上无吸附格', fromCell);
|
|
host.drainLogicPresentation(ctx);
|
|
return;
|
|
}
|
|
|
|
const waypoints = this.buildRetreatWorlds(session, ctx.caster, snap, params);
|
|
if (waypoints.length < 1) {
|
|
console.log('[NezhaCharge] 反向路径为空');
|
|
host.drainLogicPresentation(ctx);
|
|
return;
|
|
}
|
|
|
|
const parent = SkillPresentationEnv.flyLayer ?? map.ensureHeroParent();
|
|
if (!parent?.isValid) {
|
|
console.log('[NezhaCharge] 无父节点');
|
|
host.drainLogicPresentation(ctx);
|
|
return;
|
|
}
|
|
|
|
let node: Node | null = null;
|
|
try {
|
|
node = gg.res.getNode(GPath.HeroSkillPrefab(), CHARGE_PREFAB, GBundle.BattleHero);
|
|
} catch (err) {
|
|
console.log('[NezhaCharge] getNode 失败', String(err));
|
|
host.drainLogicPresentation(ctx);
|
|
return;
|
|
}
|
|
if (!node?.isValid) {
|
|
console.log('[NezhaCharge] 预制体未加载', CHARGE_PREFAB);
|
|
host.drainLogicPresentation(ctx);
|
|
return;
|
|
}
|
|
|
|
const spear = this._findNamed(node, SPEAR_NODE);
|
|
if (!spear?.isValid) {
|
|
console.log('[NezhaCharge] 缺少节点', SPEAR_NODE);
|
|
gg.res.putNode(node);
|
|
host.drainLogicPresentation(ctx);
|
|
return;
|
|
}
|
|
|
|
const seq = host.beginPresentationCast(ctx);
|
|
host.disarmAnimComplete();
|
|
host.visualChannel.clearCastCompletes();
|
|
parent.active = true;
|
|
node.active = true;
|
|
node.layer = parent.layer;
|
|
node.parent = parent;
|
|
node.setSiblingIndex(parent.children.length - 1);
|
|
node.setScale(1, 1, 1);
|
|
const spearAngle = this._chargeAimDeg(zhaWorld.x, zhaWorld.y, waypoints, 0) ?? 0;
|
|
spear.angle = spearAngle;
|
|
this._applySpearSkin(spear, host);
|
|
setNodeWorldPos(node, zhaWorld);
|
|
this._replayPrefabAnims(node);
|
|
|
|
const heroLook = this._beginHeroChargeIdle(ctx.caster, host);
|
|
|
|
this._runs.push({
|
|
caster: ctx.caster,
|
|
ctx,
|
|
host,
|
|
seq,
|
|
node,
|
|
spear,
|
|
waypoints,
|
|
speed: params.speed,
|
|
duration: params.duration,
|
|
scale: params.scale,
|
|
elapsed: 0,
|
|
jumpElapsed: 0,
|
|
jumpFromX: zhaWorld.x,
|
|
jumpFromY: zhaWorld.y,
|
|
jumpToX: waypoints[0].x,
|
|
jumpToY: waypoints[0].y,
|
|
traveled: 0,
|
|
spearAngle,
|
|
hitIds: [],
|
|
heroNode: heroLook.heroNode,
|
|
shadow2: heroLook.shadow2,
|
|
lastX: zhaWorld.x,
|
|
lastY: zhaWorld.y,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 推进冲锋。
|
|
* @param dt 逻辑秒(含倍速)
|
|
*/
|
|
tick(dt: number): void {
|
|
if (!(dt > 0)) return;
|
|
for (let i = this._runs.length - 1; i >= 0; i--) {
|
|
if (!this._step(this._runs[i], dt)) {
|
|
this._finish(this._runs[i], true);
|
|
this._runs.splice(i, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 结束全部冲锋并恢复显隐 */
|
|
abortAll(): void {
|
|
for (let i = this._runs.length - 1; i >= 0; i--) {
|
|
this._finish(this._runs[i], false);
|
|
}
|
|
this._runs.length = 0;
|
|
}
|
|
|
|
/**
|
|
* 结束该施法者未完成的冲锋。
|
|
* @param caster 施法者
|
|
*/
|
|
private _abortCaster(caster: EntityId): void {
|
|
for (let i = this._runs.length - 1; i >= 0; i--) {
|
|
if (this._runs[i].caster !== caster) continue;
|
|
this._finish(this._runs[i], false);
|
|
this._runs.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 单帧:跳出、沿路位移、命中。返回 false 表示结束。
|
|
* @param run 运行态
|
|
* @param dt 逻辑秒
|
|
*/
|
|
private _step(run: NezhaChargeRun, dt: number): boolean {
|
|
if (!run.ctx.isAlive || !run.host.isPresentationCast(run.seq) || !run.node.isValid) {
|
|
if (run.jumpElapsed < JUMP_TOTAL) {
|
|
console.log(
|
|
'[NezhaCharge] 跳出被打断',
|
|
'alive=', run.ctx.isAlive,
|
|
'cast=', run.host.isPresentationCast(run.seq),
|
|
'node=', run.node.isValid,
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
if (run.jumpElapsed < JUMP_TOTAL) {
|
|
run.jumpElapsed += dt;
|
|
this._applyJump(run);
|
|
this._turnSpear(run, dt, this._chargeAimDeg(run.lastX, run.lastY, run.waypoints, 0));
|
|
this._hitNear(run);
|
|
return true;
|
|
}
|
|
|
|
run.elapsed += dt;
|
|
const cap = run.speed * Math.min(run.duration, run.elapsed);
|
|
this._pointAlong(run.waypoints, cap, this._tmp);
|
|
setNodeWorldPos(run.node, this._tmp);
|
|
run.lastX = this._tmp.x;
|
|
run.lastY = this._tmp.y;
|
|
run.traveled = cap;
|
|
this._turnSpear(run, dt, this._chargeAimDeg(run.lastX, run.lastY, run.waypoints, cap));
|
|
|
|
this._hitNear(run);
|
|
|
|
const pathLen = this._pathLen(run.waypoints);
|
|
if (run.elapsed >= run.duration || cap >= pathLen - 0.5) return false;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 冲锋体附近敌对怪各伤一次。
|
|
* @param run 运行态
|
|
*/
|
|
private _hitNear(run: NezhaChargeRun): void {
|
|
const session = SkillPresentationEnv.getBattleSession();
|
|
if (!session || !session.world.isAlive(run.caster)) return;
|
|
this._hitRun = run;
|
|
session.world.view(HealthComp).forEach(this._hitOne);
|
|
this._hitRun = null;
|
|
}
|
|
|
|
/**
|
|
* 单体命中判定(forEach 回调)。
|
|
* @param id 实体
|
|
* @param hp 生命
|
|
*/
|
|
private readonly _hitOne = (id: EntityId, hp: HealthComp): void => {
|
|
const run = this._hitRun;
|
|
const session = SkillPresentationEnv.getBattleSession();
|
|
if (!run || !session) return;
|
|
if (id === run.caster) return;
|
|
if (this._alreadyHit(run, id)) 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;
|
|
const casterTeam = session.world.getComponent(run.caster, TeamComp);
|
|
if (!BattleTeamId.isHostile(casterTeam, session.world.getComponent(id, TeamComp))) return;
|
|
const wp = SkillPresentationEnv.getTargetWorldPos(id);
|
|
if (!wp) return;
|
|
const dx = wp.x - run.lastX;
|
|
const dy = wp.y - run.lastY;
|
|
if (dx * dx + dy * dy > HIT_RADIUS * HIT_RADIUS) return;
|
|
run.hitIds.push(id);
|
|
run.ctx.dealDamage(id, { scale: run.scale, damageType: DMG_TYPE });
|
|
};
|
|
|
|
/**
|
|
* 是否已命中。
|
|
* @param run 运行态
|
|
* @param id 实体
|
|
*/
|
|
private _alreadyHit(run: NezhaChargeRun, id: EntityId): boolean {
|
|
const ids = run.hitIds;
|
|
for (let i = 0; i < ids.length; i++) {
|
|
if (ids[i] === id) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 回收并恢复显隐;可选 finish 施法锁。
|
|
* @param run 运行态
|
|
* @param releaseLock 是否 finish ctx
|
|
*/
|
|
private _finish(run: NezhaChargeRun, releaseLock: boolean): void {
|
|
this._restoreHeroChargeIdle(run);
|
|
if (run.host.isValid) run.host.visualChannel.setCaster(run.caster);
|
|
if (run.node?.isValid) {
|
|
this._clearSpine(run.node);
|
|
run.node.angle = 0;
|
|
run.node.setScale(1, 1, 1);
|
|
if (run.spear.isValid) {
|
|
run.spear.angle = 0;
|
|
this._resetSpearSkin(run.spear);
|
|
}
|
|
gg.res.putNode(run.node);
|
|
}
|
|
if (releaseLock && run.host.isPresentationCast(run.seq)) {
|
|
run.host.finishPresentationCast(run.seq, false);
|
|
} else if (!releaseLock && run.ctx.isAlive) {
|
|
run.ctx.cancel('aborted');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 冲锋期间:英雄保持可见,播「待机单字」,藏阴影第二节点与武器。
|
|
* @param caster 施法者
|
|
* @param host Skill
|
|
*/
|
|
private _beginHeroChargeIdle(caster: EntityId, host: Skill): { heroNode: Node | null; shadow2: Node | null } {
|
|
const actor = HeroActorHost.instance.getActor(caster);
|
|
const node = actor?.node ?? host.node;
|
|
if (!node?.isValid) return { heroNode: null, shadow2: null };
|
|
host.visualChannel.hideWeapon();
|
|
const skin = node.getComponent(HeroSkin);
|
|
const anim = skin?.anim;
|
|
if (!anim?.isValid || !BattleSpinePlayback.play(anim, IDLE_SINGLE, { loop: true })) {
|
|
console.warn('[NezhaCharge] 缺少动画', IDLE_SINGLE, caster);
|
|
}
|
|
// 只藏「吒」脚下影 ebg-001;「哪」的 ebg 须保持显示
|
|
const shadow2 = this._findShadowSecond(skin?.displayNode ?? node);
|
|
if (shadow2) {
|
|
shadow2.active = false;
|
|
} else {
|
|
console.warn('[NezhaCharge] 阴影第二节点缺失', caster);
|
|
}
|
|
return { heroNode: node, shadow2 };
|
|
}
|
|
|
|
/** 按名取阴影第二节点,避免 children 顺序变化藏错 */
|
|
private _findShadowSecond(display: Node | null): Node | null {
|
|
const shadowRoot = display?.getChildByName(SHADOW_ROOT);
|
|
if (!shadowRoot?.isValid) return null;
|
|
const byName = shadowRoot.getChildByName(SHADOW_SECOND);
|
|
if (byName?.isValid) return byName;
|
|
return shadowRoot.children[1]?.isValid ? shadowRoot.children[1] : null;
|
|
}
|
|
|
|
/**
|
|
* 冲锋结束:回「待机」,恢复阴影第二节点。
|
|
* @param run 运行态
|
|
*/
|
|
private _restoreHeroChargeIdle(run: NezhaChargeRun): void {
|
|
// 引用可能已随换皮失效:优先用存的节点,否则从当前展示重找
|
|
let shadow2 = run.shadow2?.isValid ? run.shadow2 : null;
|
|
if (!shadow2 && run.heroNode?.isValid) {
|
|
const skin = run.heroNode.getComponent(HeroSkin);
|
|
shadow2 = this._findShadowSecond(skin?.displayNode ?? run.heroNode);
|
|
}
|
|
if (shadow2) shadow2.active = true;
|
|
if (!run.heroNode?.isValid) return;
|
|
const anim = run.heroNode.getComponent(HeroSkin)?.anim;
|
|
if (anim?.isValid) BattleSpinePlayback.play(anim, IDLE_ANIM, { loop: true, optional: true });
|
|
}
|
|
|
|
/**
|
|
* 吒字格;无 index 时用世界坐标反查。
|
|
* @param zhaIdx 地图格
|
|
* @param zhaWorld 吒世界坐标
|
|
* @param map 地图
|
|
*/
|
|
private _zhaCell(zhaIdx: number | null, zhaWorld: Readonly<Vec3>, map: MapManager): Cell | null {
|
|
if (zhaIdx != null) {
|
|
const rc = SkillPresentationEnv.getMapGridRowCol(zhaIdx);
|
|
if (rc) return { row: rc.row, col: rc.col };
|
|
}
|
|
const grid = map.getMapGridByWorldPos(zhaWorld as Vec3);
|
|
if (!grid) return null;
|
|
return { row: grid.Row, col: grid.Col };
|
|
}
|
|
|
|
/**
|
|
* 攻我方进路上离吒最近的格(按格距,不经任意可走格二次吸附)。
|
|
* @param session 会话
|
|
* @param caster 施法者
|
|
* @param zhaCell 吒字格
|
|
*/
|
|
private _snapApproachCell(session: BattleSession, caster: EntityId, zhaCell: Cell): { Row: number; Col: number } | null {
|
|
const enemyPort = this._enemyPort(session, caster);
|
|
const spawn = session.topology.getSpawnCell(enemyPort);
|
|
if (!spawn) {
|
|
console.log('[NezhaCharge] 敌方出怪口缺失', enemyPort);
|
|
return null;
|
|
}
|
|
const cell = session.pathfinder.nearestApproachCell(zhaCell, spawn, enemyPort);
|
|
if (!cell) return null;
|
|
return { Row: cell.row, Col: cell.col };
|
|
}
|
|
|
|
/**
|
|
* 攻向施法者阵营的出怪口。
|
|
* @param session 会话
|
|
* @param caster 施法者
|
|
*/
|
|
private _enemyPort(session: BattleSession, caster: EntityId): number {
|
|
const team = session.world.getComponent(caster, TeamComp);
|
|
const owner = team?.ownerId ?? team?.team ?? 0;
|
|
return owner === 0 ? 1 : 2;
|
|
}
|
|
|
|
/**
|
|
* 从吸附格沿攻我方的怪物进路退向敌方出怪口。
|
|
* @param session 会话
|
|
* @param caster 施法者
|
|
* @param snap 吸附格
|
|
* @param params 配表
|
|
*/
|
|
private buildRetreatWorlds(session: BattleSession, caster: EntityId, snap: { Row: number; Col: number }, params: NezhaChargeParams): Vec3[] {
|
|
const enemyPort = this._enemyPort(session, caster);
|
|
const spawn = session.topology.getSpawnCell(enemyPort);
|
|
if (!spawn) {
|
|
console.log('[NezhaCharge] 敌方出怪口缺失', enemyPort);
|
|
return [];
|
|
}
|
|
const cellSize = Math.max(1, BattleMapSpec.CELL_SIZE);
|
|
const maxSteps = Math.ceil(params.speed * params.duration / cellSize) + 8;
|
|
const from: Cell = { row: snap.Row, col: snap.Col };
|
|
const cells = session.pathfinder.buildRetreatPath(from, maxSteps, spawn, enemyPort);
|
|
const out: Vec3[] = [];
|
|
for (let i = 0; i < cells.length; i++) {
|
|
const w = SkillPresentationEnv.getGridWorldCenterByRowCol(cells[i].row, cells[i].col);
|
|
if (w) out.push(w.clone());
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* 沿折线取点。
|
|
* @param points 路点
|
|
* @param dist 路程
|
|
* @param out 输出
|
|
*/
|
|
private _pointAlong(points: readonly Vec3[], dist: number, out: Vec3): void {
|
|
if (points.length === 0) {
|
|
out.set(0, 0, 0);
|
|
return;
|
|
}
|
|
if (points.length === 1 || dist <= 0) {
|
|
out.set(points[0].x, points[0].y, points[0].z);
|
|
return;
|
|
}
|
|
let left = dist;
|
|
for (let i = 0; i < points.length - 1; i++) {
|
|
const a = points[i];
|
|
const b = points[i + 1];
|
|
const dx = b.x - a.x;
|
|
const dy = b.y - a.y;
|
|
const seg = Math.sqrt(dx * dx + dy * dy);
|
|
if (seg <= 1e-4) continue;
|
|
if (left <= seg) {
|
|
const t = left / seg;
|
|
out.set(a.x + dx * t, a.y + dy * t, a.z);
|
|
return;
|
|
}
|
|
left -= seg;
|
|
}
|
|
const last = points[points.length - 1];
|
|
out.set(last.x, last.y, last.z);
|
|
}
|
|
|
|
/**
|
|
* 折线总长。
|
|
* @param points 路点
|
|
*/
|
|
private _pathLen(points: readonly Vec3[]): number {
|
|
let len = 0;
|
|
for (let i = 0; i < points.length - 1; i++) {
|
|
const dx = points[i + 1].x - points[i].x;
|
|
const dy = points[i + 1].y - points[i].y;
|
|
len += Math.sqrt(dx * dx + dy * dy);
|
|
}
|
|
return len;
|
|
}
|
|
|
|
/**
|
|
* 深度查找子节点。
|
|
* @param root 根
|
|
* @param name 名
|
|
*/
|
|
private _findNamed(root: Node, name: string): Node | null {
|
|
const stack: Node[] = [root];
|
|
while (stack.length > 0) {
|
|
const n = stack.pop()!;
|
|
if (n.name === name) return n;
|
|
const ch = n.children;
|
|
for (let i = 0; i < ch.length; i++) stack.push(ch[i]);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 冲锋枪切到当前 Weapon.skin。
|
|
* @param spear 冲锋枪节点
|
|
* @param host Skill 宿主
|
|
*/
|
|
private _applySpearSkin(spear: Node, host: Skill): void {
|
|
const sk = spear.getComponent(sp.Skeleton) ?? spear.getComponentInChildren(sp.Skeleton);
|
|
if (!sk?.isValid) {
|
|
console.log('[NezhaCharge] 冲锋枪缺 Spine');
|
|
return;
|
|
}
|
|
const skin = String(host.visualChannel.weapon.skinName ?? '').trim();
|
|
if (!skin) {
|
|
console.log('[NezhaCharge] 当前武器皮肤为空', host.visualChannel.weapon.weaponId);
|
|
return;
|
|
}
|
|
applyWeaponSpineSkin(sk, skin, '[NezhaCharge]');
|
|
}
|
|
|
|
/**
|
|
* 回池前把冲锋枪皮还原为预制体 defaultSkin。
|
|
* @param spear 冲锋枪节点
|
|
*/
|
|
private _resetSpearSkin(spear: Node): void {
|
|
const sk = spear.getComponent(sp.Skeleton) ?? spear.getComponentInChildren(sp.Skeleton);
|
|
if (!sk?.isValid) return;
|
|
const def = String((sk as sp.Skeleton & { defaultSkin?: string }).defaultSkin ?? '').trim();
|
|
if (!def) return;
|
|
applyWeaponSpineSkin(sk, def, '[NezhaCharge]');
|
|
}
|
|
|
|
/**
|
|
* 各 Spine 只重播自己的预制体默认动画,不改冲锋枪朝向。
|
|
* @param root 冲锋根
|
|
*/
|
|
private _replayPrefabAnims(root: Node): void {
|
|
const body = this._findNamed(root, BODY_NODE);
|
|
if (body) this._replayOwnDefault(body);
|
|
const spear = this._findNamed(root, SPEAR_NODE);
|
|
if (spear) this._replayOwnDefault(spear);
|
|
}
|
|
|
|
/**
|
|
* 重播该节点自身 defaultAnimation。
|
|
* @param node 身体或冲锋枪
|
|
*/
|
|
private _replayOwnDefault(node: Node): void {
|
|
const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton);
|
|
if (!sk?.isValid) return;
|
|
if (sk.skeletonData) ensureSpineSkeletonDataRuntime(sk.skeletonData);
|
|
const typed = sk as sp.Skeleton & { defaultAnimation?: string };
|
|
const name = String(typed.defaultAnimation || '').trim();
|
|
if (!name) return;
|
|
BattleSpinePlayback.play(sk, name, { loop: true, optional: true });
|
|
}
|
|
|
|
/**
|
|
* 取消根下全部 Spine 完成回调。
|
|
* @param root 冲锋根
|
|
*/
|
|
private _clearSpine(root: Node): void {
|
|
const list = root.getComponentsInChildren(sp.Skeleton);
|
|
for (let i = 0; i < list.length; i++) {
|
|
BattleSpinePlayback.clearPendingComplete(list[i]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 枪口对准冲锋前方(最短弧插值)。
|
|
* @param run 运行态
|
|
* @param dt 逻辑秒
|
|
* @param desired 目标角;空则不动
|
|
*/
|
|
private _turnSpear(run: NezhaChargeRun, dt: number, desired: number | null): void {
|
|
if (desired == null || !run.spear.isValid) return;
|
|
const delta = shortestAngleDeltaDeg(run.spearAngle, desired);
|
|
const maxStep = SPEAR_TURN_DEG * dt;
|
|
const step = delta > maxStep ? maxStep : (delta < -maxStep ? -maxStep : delta);
|
|
run.spearAngle += step;
|
|
run.spear.angle = run.spearAngle;
|
|
}
|
|
|
|
/**
|
|
* 沿路径前瞻的冲锋朝向(度,0=右)。
|
|
* @param fromX 当前 X
|
|
* @param fromY 当前 Y
|
|
* @param waypoints 路点
|
|
* @param traveled 已走路程
|
|
*/
|
|
private _chargeAimDeg(fromX: number, fromY: number, waypoints: readonly Vec3[], traveled: number): number | null {
|
|
this._from.set(fromX, fromY, 0);
|
|
this._pointAlong(waypoints, traveled + SPEAR_LOOKAHEAD, this._aim);
|
|
return computeAimAngleDeg(this._from, this._aim);
|
|
}
|
|
|
|
/**
|
|
* 从吒字位跳到吸附格:先蹲再弹,抛物线插值。
|
|
* @param run 运行态
|
|
*/
|
|
private _applyJump(run: NezhaChargeRun): void {
|
|
const t = run.jumpElapsed;
|
|
if (t <= JUMP_ANTICIP) {
|
|
const k = this._easeOutQuad(Math.min(1, t / JUMP_ANTICIP));
|
|
run.node.setScale(1 + 0.3 * k, 1 - 0.4 * k, 1);
|
|
this._tmp.set(run.jumpFromX, run.jumpFromY, 0);
|
|
setNodeWorldPos(run.node, this._tmp);
|
|
run.lastX = run.jumpFromX;
|
|
run.lastY = run.jumpFromY;
|
|
return;
|
|
}
|
|
const airT = Math.min(1, (t - JUMP_ANTICIP) / JUMP_AIR);
|
|
const u = this._easeOutCubic(airT);
|
|
const lift = 4 * airT * (1 - airT) * JUMP_H;
|
|
const x = run.jumpFromX + (run.jumpToX - run.jumpFromX) * u;
|
|
const y = run.jumpFromY + (run.jumpToY - run.jumpFromY) * u + lift;
|
|
this._tmp.set(x, y, 0);
|
|
setNodeWorldPos(run.node, this._tmp);
|
|
run.lastX = x;
|
|
run.lastY = y;
|
|
if (airT < 1) {
|
|
run.node.setScale(0.82 + 0.36 * airT, 1.28 - 0.5 * airT, 1);
|
|
return;
|
|
}
|
|
const settle = this._easeOutQuad(Math.min(1, (t - JUMP_ANTICIP - JUMP_AIR) / JUMP_SETTLE));
|
|
run.node.setScale(1.18 - 0.18 * settle, 0.78 + 0.22 * settle, 1);
|
|
if (settle >= 1) run.node.setScale(1, 1, 1);
|
|
}
|
|
|
|
/**
|
|
* 二次缓出。
|
|
* @param t 0~1
|
|
*/
|
|
private _easeOutQuad(t: number): number {
|
|
return 1 - (1 - t) * (1 - t);
|
|
}
|
|
|
|
/**
|
|
* 三次缓出(起跳猛)。
|
|
* @param t 0~1
|
|
*/
|
|
private _easeOutCubic(t: number): number {
|
|
const r = 1 - t;
|
|
return 1 - r * r * r;
|
|
}
|
|
}
|
|
|
|
/** Skill.skill type=1 表现入口 */
|
|
export class NezhaChargePresenter implements ISkillPresenter {
|
|
/**
|
|
* 播放哪吒冲锋。
|
|
* @param ctx 表现上下文
|
|
* @param host Skill 宿主
|
|
*/
|
|
present(ctx: SkillPresentationContext, host: Skill): void {
|
|
if (!ctx.isAlive) {
|
|
ctx.finish();
|
|
return;
|
|
}
|
|
const skillId = Math.floor(Number(ctx.params.skillId) || 0);
|
|
if (isEarthChargeSkillId(skillId)) {
|
|
getEarthChargeHost().start(ctx, host);
|
|
return;
|
|
}
|
|
getNezhaChargeHost().start(ctx, host);
|
|
}
|
|
}
|
|
|