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

758 lines
25 KiB

3 weeks ago
import { Node, UITransform, Vec3, sp } from 'cc';
import type { BattleSession, EntityId } from 'db://assets/mx/module/battle';
import { BattleTeamId } from 'db://assets/mx/module/battle';
import {
HealthComp,
MovementComp,
TeamComp,
TransformComp,
isBajiaoBlowRetreating,
isYanSoulClaimed,
} from 'db://assets/mx/module/battle/ecs/Components';
import { GBundle, GPath } from '../../game/ConfigRes';
import { applyMonsterFootOffset } from '../map/BattleMapSpec';
import { findNearestCurrentlyWalkableCell } from '../map/MapPathQueries';
import type { Skill } from '../skill/Skill';
import type { SkillPresentationContext } from '../skill/SkillPresentationTypes';
import { getBattleTimeScale } from '../skill/BattlePresentationClock';
import { SkillPresentationEnv } from '../skill/SkillPresentationEnv';
import { setNodeWorldPos } from '../skill/SkillTransformDriver';
import { BATTLE_MONSTER_CCCLASS, type IBattleMonsterView } from '../view/IBattleMonsterView';
import { BattleSpinePlayback } from './BattleSpinePlayback';
import type { ISkillPresenter } from './SkillPresenter';
/** 枪尖骨骼(枪类皮肤 bone3 在杆头) */
const TIP_BONE = 'bone3';
/** 吸入结束(动画 0.4 突刺前) */
const SUCK_UNTIL = 0.28;
/** 砸地关键帧 */
const SMASH_AT = 1.0;
/** 砸向格子时长 */
const SLAM_DUR = 0.16;
/** 落地压扁恢复 */
const SETTLE_SEC = 0.08;
/** 动画未到砸地关键帧时的最长等待(逻辑秒) */
const TOSS_MAX = 3;
/** battleHero/prefab/skill/挑飞砸地效果 */
const SMASH_PREFAB = '挑飞砸地效果';
/** 砸地 Spine 动画 */
const SMASH_ANIM = '三尖两面刀技能2';
/** 直伤类型 */
const DMG_TYPE = 'spear_toss';
/** 眩晕 buff */
const STUN_BUFF = 'debuff_4';
/** 吸入缓出 */
const SUCK_EASE = 3;
/** 砸地加速 */
const SLAM_EASE = 3;
/** Skill.skill type=7 解析结果 */
export interface SpearTossParams {
/** 圆形半径(像素) */
radius: number;
/** 伤害系数 */
scale: number;
/** 眩晕秒 */
stunSec: number;
}
/** 砸地特效 */
interface TossFx {
/** 特效节点 */
node: Node;
/** 剩余秒 */
wait: number;
}
/** 一次挑飞 */
interface SpearTossRun {
/** 施法者 */
caster: EntityId;
/** 被挑飞怪 */
victim: EntityId;
/** 表现上下文 */
ctx: SkillPresentationContext;
/** Skill 宿主 */
host: Skill;
/** 播控序号 */
seq: number;
/** 圆形半径 */
radius: number;
/** 伤害系数 */
scale: number;
/** 眩晕秒 */
stunSec: number;
/** 动画累计秒(无 trackTime 时) */
elapsed: number;
/** 0 吸入 / 1 跟随枪尖 / 2 砸地 / 3 特效 */
phase: number;
/** 吸入起点 X */
fromX: number;
/** 吸入起点 Y */
fromY: number;
/** 砸地起点 X */
slamFromX: number;
/** 砸地起点 Y */
slamFromY: number;
/** 落点 X */
landX: number;
/** 落点 Y */
landY: number;
/** 落点行 */
landRow: number;
/** 落点列 */
landCol: number;
/** 砸地已过秒(动画时间) */
slamT: number;
/** 落地后特效剩余秒 */
fxWait: number;
/** 落地等待初值 */
fxMax: number;
/** 默认缩放 X */
scaleX: number;
/** 默认缩放 Y */
scaleY: number;
}
let _host: SpearTossHost | null = null;
/** 挑飞砸地运行时 */
export function getSpearTossHost(): SpearTossHost {
if (!_host) _host = new SpearTossHost();
return _host;
}
/** 挑飞砸地:吸到枪尖、随动画挑起、砸到最近可行走格 */
export class SpearTossHost {
private readonly _runs: SpearTossRun[] = [];
private readonly _fx: TossFx[] = [];
private readonly _tmp = new Vec3();
private readonly _tip = new Vec3();
private readonly _boneLocal = new Vec3();
private readonly _land = new Vec3();
/** 当前范围扫描所属施法 */
private _hitRun: SpearTossRun | null = null;
private _hitX = 0;
private _hitY = 0;
private _tipWarned = false;
/**
* 解析 `7,半径,伤害万分比,眩晕秒`。
* @param raw Skill.skill
*/
static parse(raw: string): SpearTossParams | null {
const parts = String(raw ?? '').split(',').map((s) => Number(String(s).trim()));
if (parts[0] !== 7) return null;
const radius = parts[1];
const wan = parts[2];
const stunSec = parts[3];
if (!(radius > 0) || !(wan > 0) || !(stunSec >= 0)) {
console.log('[SpearToss] skill 参数无效');
return null;
}
return { radius, scale: wan / 10000, stunSec };
}
/**
* 开战挑飞。
* @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 = SpearTossHost.parse(raw);
if (!params) {
host.drainLogicPresentation(ctx);
return;
}
const session = SkillPresentationEnv.getBattleSession();
const map = SkillPresentationEnv.getMap();
if (!session || !map) {
console.log('[SpearToss] 无 session/map');
host.drainLogicPresentation(ctx);
return;
}
const victim = ctx.targets[0];
if (victim == null) {
console.log('[SpearToss] 无目标', ctx.params.skillId);
host.drainLogicPresentation(ctx);
return;
}
const node = SkillPresentationEnv.getMonsterNode(victim);
if (!node?.isValid) {
console.log('[SpearToss] 目标无节点', victim);
host.drainLogicPresentation(ctx);
return;
}
const move = session.world.getComponent(victim, MovementComp);
if (!move || move.spawnProtected || isYanSoulClaimed(move) || isBajiaoBlowRetreating(move)) {
console.log('[SpearToss] 目标不可挑飞', victim);
host.drainLogicPresentation(ctx);
return;
}
const animation = String(ctx.params.animation ?? '').trim();
if (!animation) {
console.log('[SpearToss] 缺 animation', ctx.params.skillId);
host.drainLogicPresentation(ctx);
return;
}
const follow = String(ctx.params.skillKind ?? '') === 'weapon' || host.visualChannel.useWeapon;
if (!host.visualChannel.ensureWeapon()) {
console.log('[SpearToss] 无武器', ctx.params.skillId);
host.drainLogicPresentation(ctx);
return;
}
const seq = host.beginPresentationCast(ctx);
if (!(seq > 0)) return;
host.visualChannel.playCastAnim({
caster: ctx.caster,
animation,
followWeapon: follow,
skillKind: String(ctx.params.skillKind ?? ''),
primaryTarget: victim,
holdCasting: true,
animTimeScale: 1,
onAnimComplete: this._noopAnim,
});
move.presentLocked = true;
SkillPresentationEnv.holdMonsterVisual(victim);
this._runs.push({
caster: ctx.caster,
victim,
ctx,
host,
seq,
radius: params.radius,
scale: params.scale,
stunSec: params.stunSec,
elapsed: 0,
phase: 0,
fromX: node.worldPosition.x,
fromY: node.worldPosition.y,
slamFromX: 0,
slamFromY: 0,
landX: 0,
landY: 0,
landRow: -1,
landCol: -1,
slamT: 0,
fxWait: 0,
fxMax: SETTLE_SEC,
scaleX: node.scale.x,
scaleY: node.scale.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);
}
}
this._tickFx(dt);
}
/** 结束全部挑飞 */
abortAll(): void {
for (let i = this._runs.length - 1; i >= 0; i--) {
this._finish(this._runs[i], false);
}
this._runs.length = 0;
this._dropFx();
}
/**
* 结束该施法者未完成的挑飞。
* @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);
}
}
/** 武器动画结束占位(由 Host 收招) */
private readonly _noopAnim = (): void => {};
/**
* 单帧。返回 false 表示结束。
* @param run 运行态
* @param dt 逻辑秒
*/
private _step(run: SpearTossRun, dt: number): boolean {
if (!run.ctx.isAlive || !run.host.isPresentationCast(run.seq)) return false;
const node = SkillPresentationEnv.getMonsterNode(run.victim);
if (!node?.isValid) return false;
run.elapsed += this._animDt(run, dt);
const track = this._trackTime(run);
if (run.elapsed > TOSS_MAX && run.phase < 2) {
console.log('[SpearToss] 动画未到砸地关键帧,结束挑飞', track, run.elapsed);
return false;
}
if (run.phase === 0) {
this._applySuck(run, node, track);
if (track >= SUCK_UNTIL) run.phase = 1;
return true;
}
if (run.phase === 1) {
if (!this._stickTip(run, node)) return false;
if (track >= SMASH_AT) {
if (!this._beginSlam(run, node)) return false;
run.phase = 2;
}
return true;
}
if (run.phase === 2) {
run.slamT += this._animDt(run, dt);
this._applySlam(run, node);
if (run.slamT >= SLAM_DUR) {
this._onLand(run, node);
run.phase = 3;
}
return true;
}
run.fxWait -= dt;
this._applySettle(run, node);
return run.fxWait > 0;
}
/**
* 吸入枪尖。
* @param run 运行态
* @param node 怪节点
* @param track 动画时间
*/
private _applySuck(run: SpearTossRun, node: Node, track: number): void {
if (!this._readTip(run, this._tip)) {
this._tip.set(run.fromX, run.fromY, node.worldPosition.z);
}
const u = this._easeOut(Math.min(1, Math.max(0, track / SUCK_UNTIL)), SUCK_EASE);
this._tmp.set(
run.fromX + (this._tip.x - run.fromX) * u,
run.fromY + (this._tip.y - run.fromY) * u,
node.worldPosition.z,
);
setNodeWorldPos(node, this._tmp);
const lift = 1 + 0.08 * u;
node.setScale(run.scaleX / lift, run.scaleY * lift, 1);
}
/**
* 贴枪尖。
* @param run 运行态
* @param node 怪节点
*/
private _stickTip(run: SpearTossRun, node: Node): boolean {
if (!this._readTip(run, this._tip)) return false;
this._tmp.set(this._tip.x, this._tip.y, node.worldPosition.z);
setNodeWorldPos(node, this._tmp);
return true;
}
/**
* 锁定砸地落点。
* @param run 运行态
* @param node 怪节点
*/
private _beginSlam(run: SpearTossRun, node: Node): boolean {
run.slamFromX = node.worldPosition.x;
run.slamFromY = node.worldPosition.y;
run.slamT = 0;
const session = SkillPresentationEnv.getBattleSession();
const map = SkillPresentationEnv.getMap();
if (!session || !map) {
console.log('[SpearToss] 砸地时无 session/map');
return false;
}
if (!this._readTip(run, this._tip)) {
this._tip.set(run.slamFromX, run.slamFromY, 0);
}
const grid = map.getMapGridByWorldPos(this._tip);
if (!grid) {
console.log('[SpearToss] 枪尖不在地图格上', this._tip.x, this._tip.y);
return false;
}
const snap = findNearestCurrentlyWalkableCell(map, session, { row: grid.Row, col: grid.Col });
if (!snap) {
console.log('[SpearToss] 枪尖附近无可行走格', grid.Row, grid.Col);
return false;
}
const center = SkillPresentationEnv.getGridWorldCenterByRowCol(snap.Row, snap.Col);
if (!center) {
console.log('[SpearToss] 落点格无世界坐标', snap.Row, snap.Col);
return false;
}
applyMonsterFootOffset(center, this._land);
run.landX = this._land.x;
run.landY = this._land.y;
run.landRow = snap.Row;
run.landCol = snap.Col;
return true;
}
/**
* 砸向落点(加速下落)。
* @param run 运行态
* @param node 怪节点
*/
private _applySlam(run: SpearTossRun, node: Node): void {
const u = this._easeIn(Math.min(1, run.slamT / SLAM_DUR), SLAM_EASE);
this._tmp.set(
run.slamFromX + (run.landX - run.slamFromX) * u,
run.slamFromY + (run.landY - run.slamFromY) * u,
node.worldPosition.z,
);
setNodeWorldPos(node, this._tmp);
const squash = u * u;
node.setScale(run.scaleX * (1 + 0.22 * squash), run.scaleY * (1 - 0.28 * squash), 1);
}
/**
* 落地:特效、范围伤、眩晕、同步格子。
* @param run 运行态
* @param node 怪节点
*/
private _onLand(run: SpearTossRun, node: Node): void {
this._tmp.set(run.landX, run.landY, node.worldPosition.z);
setNodeWorldPos(node, this._tmp);
node.setScale(run.scaleX * 1.18, run.scaleY * 0.72, 1);
this._playSmashFx(run, node.worldPosition.z);
this._syncLogic(run);
this._hitArea(run);
SkillPresentationEnv.getMonsterPresenter()?.sortMonstersByY();
}
/**
* 落地压扁回正。
* @param run 运行态
* @param node 怪节点
*/
private _applySettle(run: SpearTossRun, node: Node): void {
const passed = run.fxMax - run.fxWait;
const u = Math.min(1, passed / SETTLE_SEC);
node.setScale(run.scaleX * (1.18 - 0.18 * u), run.scaleY * (0.72 + 0.28 * u), 1);
if (u >= 1) node.setScale(run.scaleX, run.scaleY, 1);
}
/**
* 写入逻辑格并重寻路(仍存活时)。
* @param run 运行态
*/
private _syncLogic(run: SpearTossRun): void {
const session = SkillPresentationEnv.getBattleSession();
if (!session?.world.isAlive(run.victim)) return;
const tf = session.world.getComponent(run.victim, TransformComp);
const move = session.world.getComponent(run.victim, MovementComp);
if (!tf || !move) return;
tf.row = run.landRow;
tf.col = run.landCol;
session.spatial.updateEntity(run.victim, { row: run.landRow, col: run.landCol });
const logical = session.grid.cellToWorld({ row: run.landRow, col: run.landCol });
move.worldX = logical.x;
move.worldY = logical.y;
move.reachedGoal = false;
move.path = [];
move.pathIndex = 0;
move.hasAttackGoal = false;
move.forcePresentSync = true;
session.movement.assignPath(run.victim);
}
/**
* 落点圆形范围伤 + 眩晕。
* @param run 运行态
*/
private _hitArea(run: SpearTossRun): void {
const session = SkillPresentationEnv.getBattleSession();
if (!session || !session.world.isAlive(run.caster)) return;
this._hitRun = run;
this._hitX = run.landX;
this._hitY = run.landY;
session.world.view(HealthComp).forEach(this._hitOne);
this._hitRun = null;
}
/**
* 单体范围判定。
* @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 (!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;
if (!this._inRadius(id, run.radius)) return;
if (run.stunSec > 0) this._stun(session, run.caster, id, run.stunSec);
run.ctx.dealDamage(id, { scale: run.scale, damageType: DMG_TYPE });
};
/**
* 是否在落点半径内(挑飞目标用节点坐标)。
* @param id 实体
* @param radius 半径
*/
private _inRadius(id: EntityId, radius: number): boolean {
const node = SkillPresentationEnv.getMonsterNode(id);
const wp = node?.isValid ? node.worldPosition : SkillPresentationEnv.getTargetWorldPos(id);
if (!wp) return false;
const dx = wp.x - this._hitX;
const dy = wp.y - this._hitY;
return dx * dx + dy * dy <= radius * radius;
}
/**
* 上眩晕。
* @param session 会话
* @param caster 施法者
* @param target 目标
* @param dur 秒
*/
private _stun(session: BattleSession, caster: EntityId, target: EntityId, dur: number): void {
if (!session.config.getBuff(STUN_BUFF)) {
console.log('[SpearToss] 缺 buff', STUN_BUFF);
return;
}
session.buffs.add(target, STUN_BUFF, caster, 1, { duration: dur });
}
/**
* 播砸地预制体。
* @param run 运行态
* @param z 世界 Z
*/
private _playSmashFx(run: SpearTossRun, z: number): void {
const parent = SkillPresentationEnv.flyLayer ?? SkillPresentationEnv.getMap()?.ensureHeroParent();
if (!parent?.isValid) {
console.log('[SpearToss] 砸地特效无父节点');
this._setFxWait(run, SETTLE_SEC);
return;
}
let node: Node | null = null;
try {
node = gg.res.getNode(GPath.HeroSkillPrefab(), SMASH_PREFAB, GBundle.BattleHero);
} catch (e) {
console.log('[SpearToss] 砸地特效加载异常', String(e));
this._setFxWait(run, SETTLE_SEC);
return;
}
if (!node?.isValid) {
console.log('[SpearToss] 砸地特效预制体缺失', SMASH_PREFAB);
this._setFxWait(run, SETTLE_SEC);
return;
}
if (!parent.active) parent.active = true;
parent.addChild(node);
node.active = true;
node.layer = parent.layer;
node.setWorldPosition(run.landX, run.landY, z);
const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton);
if (!sk?.isValid) {
console.log('[SpearToss] 砸地特效缺少 Skeleton');
gg.res.putNode(node);
this._setFxWait(run, SETTLE_SEC);
return;
}
const wait = BattleSpinePlayback.resolveLogicalCompleteDelay(sk, SMASH_ANIM);
if (wait == null) {
console.log('[SpearToss] 砸地动画不存在', SMASH_ANIM);
gg.res.putNode(node);
this._setFxWait(run, SETTLE_SEC);
return;
}
if (!BattleSpinePlayback.play(sk, SMASH_ANIM, { loop: false })) {
gg.res.putNode(node);
this._setFxWait(run, SETTLE_SEC);
return;
}
this._fx.push({ node, wait });
this._setFxWait(run, Math.max(SETTLE_SEC, wait));
}
/**
* 记录落地等待。
* @param run 运行态
* @param wait 秒
*/
private _setFxWait(run: SpearTossRun, wait: number): void {
run.fxWait = wait;
run.fxMax = wait;
}
/**
* 枪尖世界坐标。
* @param run 运行态
* @param out 输出
*/
private _readTip(run: SpearTossRun, out: Vec3): boolean {
const sk = run.host.visualChannel.weapon.skinAnim;
const aim = run.host.visualChannel.weapon.aim;
if (sk?.isValid) {
const bone = typeof sk.findBone === 'function' ? sk.findBone(TIP_BONE) : null;
const ui = sk.node?.getComponent(UITransform);
if (bone && ui) {
this._boneLocal.set(bone.worldX, bone.worldY, 0);
ui.convertToWorldSpaceAR(this._boneLocal, out);
return true;
}
}
if (aim?.isValid) {
if (!this._tipWarned) {
console.log('[SpearToss] 枪尖骨骼缺失,改用朝向节点', TIP_BONE);
this._tipWarned = true;
}
out.set(aim.worldPosition);
return true;
}
console.log('[SpearToss] 无法取得枪尖坐标');
return false;
}
/**
* 当前武器动画时间。
* @param run 运行态
*/
private _trackTime(run: SpearTossRun): number {
const sk = run.host.visualChannel.weapon.skinAnim;
const entry = sk?.isValid && typeof sk.getCurrent === 'function' ? sk.getCurrent(0) : null;
const t = entry != null ? Number(entry.trackTime) : NaN;
const spine = Number.isFinite(t) && t >= 0 ? t : 0;
return spine > run.elapsed ? spine : run.elapsed;
}
/**
* 动画时间增量(跟枪尖关键帧)。
* @param run 运行态
* @param dt 逻辑秒
*/
private _animDt(run: SpearTossRun, dt: number): number {
const sk = run.host.visualChannel.weapon.skinAnim;
const scale = sk?.isValid ? Math.max(0.01, sk.timeScale) : 1;
const battle = Math.max(0.01, getBattleTimeScale());
return dt * (scale / battle);
}
/**
* 缓出。
* @param t 0~1
* @param p 次数
*/
private _easeOut(t: number, p: number): number {
const r = 1 - t;
let v = r;
for (let i = 1; i < p; i++) v *= r;
return 1 - v;
}
/**
* 缓入。
* @param t 0~1
* @param p 次数
*/
private _easeIn(t: number, p: number): number {
let v = t;
for (let i = 1; i < p; i++) v *= t;
return v;
}
/**
* 推进砸地特效。
* @param dt 逻辑秒
*/
private _tickFx(dt: number): void {
for (let i = this._fx.length - 1; i >= 0; i--) {
const fx = this._fx[i];
fx.wait -= dt;
if (fx.wait > 0) continue;
this._recycleFx(fx.node);
this._fx.splice(i, 1);
}
}
/** 回收全部砸地特效 */
private _dropFx(): void {
for (let i = this._fx.length - 1; i >= 0; i--) {
this._recycleFx(this._fx[i].node);
}
this._fx.length = 0;
}
/**
* 砸地特效回池。
* @param node 特效
*/
private _recycleFx(node: Node): void {
if (!node?.isValid) return;
const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton);
BattleSpinePlayback.clearPendingComplete(sk);
gg.res.putNode(node);
}
/**
* 收招:解锁寻路或回收已死目标。
* @param run 运行态
* @param releaseLock 是否 finish
*/
private _finish(run: SpearTossRun, releaseLock: boolean): void {
const node = SkillPresentationEnv.getMonsterNode(run.victim);
if (node?.isValid) node.setScale(run.scaleX, run.scaleY, 1);
const session = SkillPresentationEnv.getBattleSession();
if (session?.world.isAlive(run.victim)) {
const move = session.world.getComponent(run.victim, MovementComp);
if (move) {
move.presentLocked = false;
move.forcePresentSync = true;
}
if (run.landRow >= 0) this._syncLogic(run);
else session.movement.assignPath(run.victim);
const view = node?.getComponent(BATTLE_MONSTER_CCCLASS) as IBattleMonsterView | null;
view?.playWalk?.();
}
SkillPresentationEnv.releaseMonsterVisual(run.victim);
const host = run.host;
if (!host?.isValid || !host.visualChannel) {
console.log('[SpearToss] 收招时宿主已销毁', run.seq);
if (!releaseLock && run.ctx.isAlive) run.ctx.cancel('aborted');
return;
}
host.visualChannel.endHoldCast();
if (releaseLock && host.isPresentationCast(run.seq)) {
host.finishPresentationCast(run.seq, false);
} else if (!releaseLock && run.ctx.isAlive) {
run.ctx.cancel('aborted');
}
}
}
/** Skill.skill type=7 表现入口 */
export class SpearTossPresenter implements ISkillPresenter {
/**
* 播放挑飞砸地。
* @param ctx 表现上下文
* @param host Skill 宿主
*/
present(ctx: SkillPresentationContext, host: Skill): void {
if (!ctx.isAlive) {
ctx.finish();
return;
}
getSpearTossHost().start(ctx, host);
}
}