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

656 lines
22 KiB

import { Node, Vec3, sp } from 'cc';
import {
HealthComp,
MovementComp,
TeamComp,
isBajiaoBlowRetreating,
isYanSoulClaimed,
} from 'db://assets/mx/module/battle/ecs/Components';
import { BattleTeamId, type BattleSession } from 'db://assets/mx/module/battle';
import type { EntityId } from 'db://assets/mx/module/ecs';
import { GBundle, GPath } from '../../game/ConfigRes';
import { BattleMapSpec } from '../map/BattleMapSpec';
import type { MapManager } from '../map/legacy/MapManager';
import { BOW_ATK_ARROW_SPEED } from '../combat/BowAtkRuntime';
import { BOW_RAIN_AIM_DEG } from '../view/HeroWeaponMount';
import type { Skill } from '../skill/Skill';
import type { SkillPresentationContext } from '../skill/SkillPresentationTypes';
import { SkillPresentationEnv } from '../skill/SkillPresentationEnv';
import { SkillTransformDriver } from '../skill/SkillTransformDriver';
import { BattleSpinePlayback } from './BattleSpinePlayback';
import type { ISkillPresenter } from './SkillPresenter';
/** 落点相对格心的最大偏移(格边比例) */
const LAND_JITTER = 0.35;
/** 抛物线峰至少高出目标的像素 */
const ARC_ABOVE = 56;
/** 落地命中半径(像素) */
const HIT_RADIUS = BattleMapSpec.CELL_SIZE * 0.55;
/** 最短飞行秒 */
const MIN_FLY = 0.12;
/** 直伤类型 */
const DMG_TYPE = 'arrow_rain';
/** battleHero/prefab/skill/万箭齐发落地效果 */
const LAND_FX_PREFAB = '万箭齐发落地效果';
/** 落地 Spine 动画 */
const LAND_FX_ANIM = '技能落地效果';
/** Skill.skill type=2 解析结果 */
export interface ArrowRainParams {
/** 发射间隔(秒) */
interval: number;
/** 每格箭数 */
perCell: number;
/** 伤害系数 */
scale: number;
}
/** 待发一箭 */
interface RainShot {
/** 落点 X */
toX: number;
/** 落点 Y */
toY: number;
}
/** 落地特效 */
interface RainLandFx {
/** 特效节点 */
node: Node;
/** 剩余秒 */
wait: number;
}
/** 在飞一箭 */
interface RainFly {
/** 箭节点 */
node: Node;
/** 落点 X */
toX: number;
/** 落点 Y */
toY: number;
/** 所属施法 */
run: ArrowRainRun;
}
/** 一次万箭齐发 */
interface ArrowRainRun {
/** 施法者 */
caster: EntityId;
/** 表现上下文 */
ctx: SkillPresentationContext;
/** Skill 宿主 */
host: Skill;
/** 播控序号 */
seq: number;
/** 发射间隔 */
interval: number;
/** 伤害系数 */
scale: number;
/** 待发队列 */
shots: RainShot[];
/** 下一发下标 */
next: number;
/** 距下一发剩余秒 */
wait: number;
/** 已发未落地 */
flying: number;
/** 发射动画倍速 */
fireScale: number;
/** 武器/字形释放动画名 */
animation: string;
/** 最近一发动画剩余秒 */
fireWait: number;
}
let _host: ArrowRainHost | null = null;
/** 万箭齐发运行时 */
export function getArrowRainHost(): ArrowRainHost {
if (!_host) _host = new ArrowRainHost();
return _host;
}
/** 万箭齐发:向怪物通行格抛物线射当前武器箭 */
export class ArrowRainHost {
private readonly _runs: ArrowRainRun[] = [];
private readonly _flies: RainFly[] = [];
private readonly _landFx: RainLandFx[] = [];
private readonly _from = new Vec3();
private readonly _to = new Vec3();
private readonly _ctrl = new Vec3();
/** 落地扫描所属施法 */
private _hitRun: ArrowRainRun | null = null;
private _hitX = 0;
private _hitY = 0;
/**
* 解析 `2,间隔,每格数量,伤害万分比`。
* @param raw Skill.skill
*/
static parse(raw: string): ArrowRainParams | null {
const parts = String(raw ?? '').split(',').map((s) => Number(String(s).trim()));
if (parts[0] !== 2) return null;
const interval = parts[1];
const perCell = Math.floor(parts[2]);
const wan = parts[3];
if (!(interval >= 0) || !(perCell > 0) || !(wan > 0)) {
console.log('[ArrowRain] skill 参数无效');
return null;
}
return { interval, perCell, 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 = ArrowRainHost.parse(raw);
if (!params) {
host.drainLogicPresentation(ctx);
return;
}
const session = SkillPresentationEnv.getBattleSession();
const map = SkillPresentationEnv.getMap();
if (!session || !map) {
console.log('[ArrowRain] 无 session/map');
host.drainLogicPresentation(ctx);
return;
}
host.visualChannel.ensureWeapon();
const template = host.visualChannel.weapon.arrowTemplate;
if (!template?.isValid) {
console.log('[ArrowRain] 当前武器无投掷物', ctx.params.skillId);
host.drainLogicPresentation(ctx);
return;
}
const shots: RainShot[] = [];
this._fillShots(session, map, ctx.caster, params.perCell, shots);
if (shots.length < 1) {
console.log('[ArrowRain] 无怪物通行格');
host.drainLogicPresentation(ctx);
return;
}
const seq = host.beginPresentationCast(ctx);
if (!(seq > 0)) return;
const animation = String(ctx.params.animation ?? '').trim() || '攻击';
const fireScale = this._resolveFireScale(host, animation, params.interval);
this._runs.push({
caster: ctx.caster,
ctx,
host,
seq,
interval: params.interval,
scale: params.scale,
shots,
next: 0,
wait: 0,
flying: 0,
fireScale,
animation,
fireWait: 0,
});
}
/**
* 推进发射。
* @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._tickLandFx(dt);
}
/** 结束全部齐射 */
abortAll(): void {
for (let i = this._runs.length - 1; i >= 0; i--) {
this._finish(this._runs[i], false);
}
this._runs.length = 0;
this._dropLandFx();
}
/**
* 结束该施法者未完成的齐射。
* @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: ArrowRainRun, dt: number): boolean {
if (!run.ctx.isAlive || !run.host.isPresentationCast(run.seq)) {
this._dropFlies(run);
return false;
}
run.wait -= dt;
run.fireWait -= dt;
while (run.next < run.shots.length && run.wait <= 0) {
this._launch(run, run.shots[run.next]);
run.next++;
run.wait += run.interval;
}
return run.next < run.shots.length || run.flying > 0 || run.fireWait > 0;
}
/**
* 发一箭。
* @param run 运行态
* @param shot 落点
*/
private _launch(run: ArrowRainRun, shot: RainShot): void {
const template = run.host.visualChannel.weapon.arrowTemplate;
const from = template?.isValid
? template.worldPosition
: SkillPresentationEnv.getCasterGridCenter(run.caster);
if (!from) {
console.log('[ArrowRain] 无发射坐标', run.caster);
return;
}
this._from.set(from.x, from.y, from.z);
this._to.set(shot.toX, shot.toY, from.z);
this._playFireAnim(run);
this._fillCtrl(this._from, this._to);
const clone = run.host.visualChannel.spawnFlyArrow(this._from);
if (!clone) return;
const dist = Vec3.distance(this._from, this._to);
const duration = Math.max(MIN_FLY, dist / BOW_ATK_ARROW_SPEED);
run.flying++;
this._flies.push({ node: clone, toX: shot.toX, toY: shot.toY, run });
SkillTransformDriver.animateParabola({
node: clone,
duration,
fromWorld: this._from,
toWorld: this._to,
controlWorld: this._ctrl,
onComplete: this._onArrived,
});
}
/** 箭落地:伤害后回收 */
private readonly _onArrived = (node?: Node): void => {
const idx = this._flyIndex(node);
if (idx < 0) {
if (node) this._recycleOrphan(node);
return;
}
const fly = this._flies[idx];
this._flies.splice(idx, 1);
fly.run.flying--;
const z = fly.node.isValid ? fly.node.worldPosition.z : 0;
this._playLandFx(fly.toX, fly.toY, z);
if (fly.run.ctx.isAlive) this._hitLand(fly.run, fly.toX, fly.toY);
fly.run.host.visualChannel.recycleFlyArrow(fly.node);
};
/**
* 落地格附近敌对怪受伤。
* @param run 运行态
* @param x 落点 X
* @param y 落点 Y
*/
private _hitLand(run: ArrowRainRun, x: number, y: number): void {
const session = SkillPresentationEnv.getBattleSession();
if (!session || !session.world.isAlive(run.caster)) return;
this._hitRun = run;
this._hitX = x;
this._hitY = y;
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;
const wp = SkillPresentationEnv.getTargetWorldPos(id);
if (!wp) return;
const dx = wp.x - this._hitX;
const dy = wp.y - this._hitY;
if (dx * dx + dy * dy > HIT_RADIUS * HIT_RADIUS) return;
run.ctx.dealDamage(id, { scale: run.scale, damageType: DMG_TYPE });
};
/**
* 收集敌方来路通行格并按轮次排队(每轮每格一箭)。
* 多区域图再限己方 AreaID:只打主区域内的敌方阵营格。
* @param session 会话
* @param map 地图
* @param caster 施法者
* @param perCell 每格箭数
* @param out 队列
*/
private _fillShots(session: BattleSession, map: MapManager, caster: EntityId, perCell: number, out: RainShot[]): void {
const enemyCamp = this._enemyPathCampArea(session, caster);
const ownArea = this._ownPlayAreaId(map, session, caster);
const grids = map.getAllCanWalkMapGrids();
const half = BattleMapSpec.CELL_SIZE * LAND_JITTER;
for (let copy = 0; copy < perCell; copy++) {
for (let i = 0; i < grids.length; i++) {
const g = grids[i];
if (ownArea != null && (g.AreaID | 0) !== ownArea) continue;
if (enemyCamp != null) {
const camp = session.topology.getCampAreaAtCell({ row: g.Row, col: g.Col });
if (camp !== enemyCamp) continue;
}
const center = SkillPresentationEnv.getGridWorldCenterByRowCol(g.Row, g.Col);
if (!center) continue;
out.push({
toX: center.x + session.rng.range(-half, half),
toY: center.y + session.rng.range(-half, half),
});
}
}
this._shuffleShots(session, out);
}
/**
* 打乱发射顺序,避免沿路径依次落下。
* @param session 会话
* @param shots 待发队列
*/
private _shuffleShots(session: BattleSession, shots: RainShot[]): void {
for (let i = shots.length - 1; i > 0; i--) {
const j = session.rng.int(0, i);
const tmp = shots[i];
shots[i] = shots[j];
shots[j] = tmp;
}
}
/**
* 发射间隔短于动画时长时加快播放。
* @param host Skill 宿主
* @param animation 释放动画名
* @param interval 发射间隔
*/
private _resolveFireScale(host: Skill, animation: string, interval: number): number {
const sk = host.visualChannel.weapon.drawAnim ?? host.visualChannel.weapon.skinAnim;
const dur = sk ? BattleSpinePlayback.getAnimDuration(sk, animation) : null;
const gap = Math.max(0.001, interval);
if (dur == null || !(dur > 0)) return 1;
return Math.max(1, dur / gap);
}
/**
* 单支出手:弓朝上拉弓,不跟落点转。
* @param run 运行态
*/
private _playFireAnim(run: ArrowRainRun): void {
const follow = String(run.ctx.params.skillKind ?? '') === 'weapon' || run.host.visualChannel.useWeapon;
run.host.visualChannel.playCastAnim({
caster: run.caster,
animation: run.animation,
followWeapon: follow,
skillKind: String(run.ctx.params.skillKind ?? ''),
skillCdSec: Number(run.ctx.params.cd) || 0,
primaryTarget: null,
skipBowArrow: true,
animTimeScale: run.fireScale,
holdCasting: true,
fixedAimDeg: BOW_RAIN_AIM_DEG,
onAnimComplete: this._noopAnim,
});
const sk = run.host.visualChannel.weapon.drawAnim ?? run.host.visualChannel.weapon.skinAnim;
const dur = sk ? BattleSpinePlayback.getAnimDuration(sk, run.animation) : null;
run.fireWait = dur != null && dur > 0 ? dur / run.fireScale : 0;
}
/**
* 施法者主区域 AreaID;通行格只有一种 AreaID 时不限制(避免单区域图空射)。
* @param map 地图
* @param session 会话
* @param caster 施法者
*/
private _ownPlayAreaId(map: MapManager, session: BattleSession, caster: EntityId): number | null {
if (!this._walkHasSplitArea(map)) return null;
const team = session.world.getComponent(caster, TeamComp);
const owner = team?.ownerId ?? team?.team ?? 0;
return owner === 0 ? 1 : 2;
}
/**
* 通行格是否出现两种及以上 AreaID。
* @param map 地图
*/
private _walkHasSplitArea(map: MapManager): boolean {
const grids = map.getAllCanWalkMapGrids();
let first = 0;
for (let i = 0; i < grids.length; i++) {
const id = grids[i].AreaID | 0;
if (id < 1) continue;
if (first === 0) {
first = id;
continue;
}
if (id !== first) return true;
}
return false;
}
/**
* 攻向施法者的敌方出怪路径 campArea;单阵营图不限制。
* @param session 会话
* @param caster 施法者
*/
private _enemyPathCampArea(session: BattleSession, caster: EntityId): number | null {
if (!session.topology.hasMultipleCampAreas()) return null;
const team = session.world.getComponent(caster, TeamComp);
const owner = team?.ownerId ?? team?.team ?? 0;
const enemyPort = owner === 0 ? 1 : 2;
const area = session.topology.getSpawnPortCampArea(enemyPort);
if (area != null && area >= 1) return area;
console.log('[ArrowRain] 敌方路径 campArea 缺失', enemyPort);
return -1;
}
/**
* 控制点:出弓沿 90° 向上,再弯向落点。
* @param from 起点
* @param to 终点
*/
private _fillCtrl(from: Readonly<Vec3>, to: Readonly<Vec3>): void {
const dist = Vec3.distance(from, to);
const ctrlLen = Math.max(ARC_ABOVE, dist * 0.42);
const rad = BOW_RAIN_AIM_DEG * Math.PI / 180;
this._ctrl.set(
from.x + Math.cos(rad) * ctrlLen,
from.y + Math.sin(rad) * ctrlLen,
from.z,
);
}
/**
* 回收并结束施法锁。
* @param run 运行态
* @param releaseLock 是否 finish
*/
private _finish(run: ArrowRainRun, releaseLock: boolean): void {
this._dropFlies(run);
const host = run.host;
if (!host?.isValid || !host.visualChannel) {
console.log('[ArrowRain] 收招时宿主已销毁', 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');
}
}
/**
* 丢掉该次施法在飞箭。
* @param run 运行态
*/
private _dropFlies(run: ArrowRainRun): void {
for (let i = this._flies.length - 1; i >= 0; i--) {
if (this._flies[i].run !== run) continue;
const node = this._flies[i].node;
this._flies.splice(i, 1);
SkillTransformDriver.cancelNode(node);
const channel = run.host?.isValid ? run.host.visualChannel : null;
if (channel) channel.recycleFlyArrow(node);
else this._recycleOrphan(node);
}
run.flying = 0;
}
/**
* 在飞箭下标。
* @param node 箭
*/
private _flyIndex(node: Node | undefined): number {
if (!node) return -1;
for (let i = 0; i < this._flies.length; i++) {
if (this._flies[i].node === node) return i;
}
return -1;
}
/**
* 无归属箭回收。
* @param node 箭
*/
private _recycleOrphan(node: Node): void {
SkillTransformDriver.cancelNode(node);
if (node.isValid) node.destroy();
}
/**
* 落点播落地预制体。
* @param x 世界 X
* @param y 世界 Y
* @param z 世界 Z
*/
private _playLandFx(x: number, y: number, z: number): void {
const parent = SkillPresentationEnv.flyLayer ?? SkillPresentationEnv.getMap()?.ensureHeroParent();
if (!parent?.isValid) {
console.log('[ArrowRain] 落地特效无父节点');
return;
}
let node: Node | null = null;
try {
node = gg.res.getNode(GPath.HeroSkillPrefab(), LAND_FX_PREFAB, GBundle.BattleHero);
} catch (e) {
console.log('[ArrowRain] 落地特效加载异常', String(e));
return;
}
if (!node?.isValid) {
console.log('[ArrowRain] 落地特效预制体缺失');
return;
}
if (!parent.active) parent.active = true;
parent.addChild(node);
node.active = true;
node.layer = parent.layer;
node.setWorldPosition(x, y, z);
const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton);
if (!sk?.isValid) {
console.log('[ArrowRain] 落地特效缺少 Skeleton');
gg.res.putNode(node);
return;
}
const wait = BattleSpinePlayback.resolveLogicalCompleteDelay(sk, LAND_FX_ANIM);
if (wait == null) {
console.log('[ArrowRain] 落地动画不存在', LAND_FX_ANIM);
gg.res.putNode(node);
return;
}
if (!BattleSpinePlayback.play(sk, LAND_FX_ANIM, { loop: false })) {
gg.res.putNode(node);
return;
}
this._landFx.push({ node, wait });
}
/**
* 推进落地特效,到时回池。
* @param dt 逻辑秒
*/
private _tickLandFx(dt: number): void {
for (let i = this._landFx.length - 1; i >= 0; i--) {
const fx = this._landFx[i];
fx.wait -= dt;
if (fx.wait > 0 && fx.node.isValid) continue;
this._landFx.splice(i, 1);
this._recycleLandFx(fx.node);
}
}
/**
* 回池落地特效。
* @param node 特效节点
*/
private _recycleLandFx(node: Node): void {
if (!node.isValid) return;
const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton);
BattleSpinePlayback.clearPendingComplete(sk);
BattleSpinePlayback.resetTracks(sk);
gg.res.putNode(node);
}
/** 回收全部落地特效 */
private _dropLandFx(): void {
for (let i = 0; i < this._landFx.length; i++) {
this._recycleLandFx(this._landFx[i].node);
}
this._landFx.length = 0;
}
/** 武器动画结束不解锁施法(等箭射完) */
private readonly _noopAnim = (): void => {
/* 锁由 Host 在队列结束后释放 */
};
}
/** Skill.skill type=2 表现入口 */
export class ArrowRainPresenter implements ISkillPresenter {
/**
* 播放万箭齐发。
* @param ctx 表现上下文
* @param host Skill 宿主
*/
present(ctx: SkillPresentationContext, host: Skill): void {
if (!ctx.isAlive) {
ctx.finish();
return;
}
getArrowRainHost().start(ctx, host);
}
}