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

786 lines
27 KiB

3 weeks ago
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';
1 week ago
import { getEarthChargeHost } from './EarthChargePresenter';
import { isEarthChargeSkillId } from '../config/weapon1/Weapon1BattleIds';
3 weeks ago
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;
2 weeks ago
return owner === 0 ? 1 : 2;
3 weeks ago
}
/**
* 从吸附格沿攻我方的怪物进路退向敌方出怪口。
* @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;
}
1 week ago
const skillId = Math.floor(Number(ctx.params.skillId) || 0);
if (isEarthChargeSkillId(skillId)) {
getEarthChargeHost().start(ctx, host);
return;
}
3 weeks ago
getNezhaChargeHost().start(ctx, host);
}
}