|
|
|
|
import type { BattleSession } from 'db://assets/mx/module/battle';
|
|
|
|
|
import { BattleTeamId } from 'db://assets/mx/module/battle';
|
|
|
|
|
import { createAbilityContext } from 'db://assets/mx/module/battle/combat/AbilityContext';
|
|
|
|
|
import {
|
|
|
|
|
HealthComp,
|
|
|
|
|
MovementComp,
|
|
|
|
|
TeamComp,
|
|
|
|
|
isBajiaoBlowRetreating,
|
|
|
|
|
isYanSoulClaimed,
|
|
|
|
|
} from 'db://assets/mx/module/battle/ecs/Components';
|
|
|
|
|
import type { EntityId } from 'db://assets/mx/module/ecs';
|
|
|
|
|
import { SkillPresentationEnv } from '../skill/SkillPresentationEnv';
|
|
|
|
|
|
|
|
|
|
/** 弓箭飞行像素/秒(逻辑与表现共用) */
|
|
|
|
|
export const BOW_ATK_ARROW_SPEED = 600;
|
|
|
|
|
/** 散射箭飞行距离 = 初始射程 × 此倍数 */
|
|
|
|
|
export const SCATTER_RANGE_MUL = 2;
|
|
|
|
|
|
|
|
|
|
/** 散射直线穿透箭 */
|
|
|
|
|
export interface ScatterRay {
|
|
|
|
|
/** 施法者 */
|
|
|
|
|
caster: EntityId;
|
|
|
|
|
/** Ability id */
|
|
|
|
|
abilityId: string;
|
|
|
|
|
/** 当前 Cocos 世界 X(与表现箭同空间) */
|
|
|
|
|
x: number;
|
|
|
|
|
/** 当前 Cocos 世界 Y(与表现箭同空间) */
|
|
|
|
|
y: number;
|
|
|
|
|
/** 方向单位 X(Cocos) */
|
|
|
|
|
ux: number;
|
|
|
|
|
/** 方向单位 Y(Cocos) */
|
|
|
|
|
uy: number;
|
|
|
|
|
/** 速度像素/秒 */
|
|
|
|
|
speed: number;
|
|
|
|
|
/** 剩余飞行秒 */
|
|
|
|
|
left: number;
|
|
|
|
|
/** 伤害倍率 */
|
|
|
|
|
scale: number;
|
|
|
|
|
/** 体盒半长 */
|
|
|
|
|
halfLen: number;
|
|
|
|
|
/** 体盒半宽 */
|
|
|
|
|
halfWid: number;
|
|
|
|
|
/** atk_buff1 */
|
|
|
|
|
buff1: string;
|
|
|
|
|
/** atk_buff2 */
|
|
|
|
|
buff2: string;
|
|
|
|
|
/** 已命中(每箭每敌一次) */
|
|
|
|
|
hitIds: EntityId[];
|
|
|
|
|
/** 是否穿透:12 穿透;17 不穿透(首中即消耗) */
|
|
|
|
|
pierce: boolean;
|
|
|
|
|
/** 非穿透命中消耗回调(回收表现箭);逻辑命中时刻触发 */
|
|
|
|
|
onConsume?: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 连发箭:落地伤锁定目标 */
|
|
|
|
|
export interface VolleyShot {
|
|
|
|
|
/** 施法者 */
|
|
|
|
|
caster: EntityId;
|
|
|
|
|
/** 锁定目标 */
|
|
|
|
|
target: EntityId;
|
|
|
|
|
/** Ability id */
|
|
|
|
|
abilityId: string;
|
|
|
|
|
/** 剩余飞行秒 */
|
|
|
|
|
wait: number;
|
|
|
|
|
/** 伤害倍率 */
|
|
|
|
|
scale: number;
|
|
|
|
|
/** atk_buff1 */
|
|
|
|
|
buff1: string;
|
|
|
|
|
/** atk_buff2 */
|
|
|
|
|
buff2: string;
|
|
|
|
|
/** 落地回收表现(已死不结算也会调用) */
|
|
|
|
|
onLand?: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* atk_type 12/13 弓箭逻辑弹道。
|
|
|
|
|
* 伤害在此结算;表现只跟轨迹。
|
|
|
|
|
*/
|
|
|
|
|
export class BowAtkHost {
|
|
|
|
|
private readonly _rays: ScatterRay[] = [];
|
|
|
|
|
private readonly _volleys: VolleyShot[] = [];
|
|
|
|
|
private _session: BattleSession | null = null;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 绑定本局会话。
|
|
|
|
|
* @param session 战斗会话
|
|
|
|
|
*/
|
|
|
|
|
bind(session: BattleSession): void {
|
|
|
|
|
if (this._session === session) return;
|
|
|
|
|
this._session = session;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 清空弹道 */
|
|
|
|
|
clear(): void {
|
|
|
|
|
for (let i = this._volleys.length - 1; i >= 0; i--) {
|
|
|
|
|
this._invokeVolleyLand(this._volleys[i]);
|
|
|
|
|
}
|
|
|
|
|
this._rays.length = 0;
|
|
|
|
|
this._volleys.length = 0;
|
|
|
|
|
this._session = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 卸下施法者在飞箭。
|
|
|
|
|
* @param caster 塔实体
|
|
|
|
|
*/
|
|
|
|
|
cancelCaster(caster: EntityId): void {
|
|
|
|
|
for (let i = this._rays.length - 1; i >= 0; i--) {
|
|
|
|
|
if (this._rays[i].caster === caster) this._rays.splice(i, 1);
|
|
|
|
|
}
|
|
|
|
|
for (let i = this._volleys.length - 1; i >= 0; i--) {
|
|
|
|
|
if (this._volleys[i].caster !== caster) continue;
|
|
|
|
|
this._invokeVolleyLand(this._volleys[i]);
|
|
|
|
|
this._volleys.splice(i, 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 散射角:中线对准目标,左右各步进;偶数十位优先往世界 +Y 一侧补。
|
|
|
|
|
* @param aimDeg 目标连线角
|
|
|
|
|
* @param count 箭数
|
|
|
|
|
* @param stepDeg 邻箭夹角
|
|
|
|
|
* @param out 写出的朝向(度)
|
|
|
|
|
*/
|
|
|
|
|
fillScatterDegs(aimDeg: number, count: number, stepDeg: number, out: number[]): void {
|
|
|
|
|
out.length = 0;
|
|
|
|
|
const n = Math.floor(count);
|
|
|
|
|
if (!(n > 0) || !(stepDeg > 0)) return;
|
|
|
|
|
out.push(aimDeg);
|
|
|
|
|
const pairs = Math.floor((n - 1) / 2);
|
|
|
|
|
for (let k = 1; k <= pairs; k++) {
|
|
|
|
|
out.push(aimDeg + k * stepDeg);
|
|
|
|
|
out.push(aimDeg - k * stepDeg);
|
|
|
|
|
}
|
|
|
|
|
if (out.length >= n) return;
|
|
|
|
|
const extraK = pairs + 1;
|
|
|
|
|
const rad = Math.PI / 180;
|
|
|
|
|
const upSin = Math.sin((aimDeg + stepDeg) * rad);
|
|
|
|
|
const dnSin = Math.sin((aimDeg - stepDeg) * rad);
|
|
|
|
|
const upSign = upSin >= dnSin ? 1 : -1;
|
|
|
|
|
out.push(aimDeg + upSign * extraK * stepDeg);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 生成一支散射箭(12 穿透 / 17 不穿透)。
|
|
|
|
|
* @param ray 弹道
|
|
|
|
|
*/
|
|
|
|
|
spawnRay(ray: ScatterRay): void {
|
|
|
|
|
this._rays.push(ray);
|
|
|
|
|
const session = this._session;
|
|
|
|
|
if (!session) return;
|
|
|
|
|
if (this._hitRay(session, ray, 0)) this._removeRay(ray);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 生成一发落地结算的连发箭。
|
|
|
|
|
* @param shot 弹道
|
|
|
|
|
*/
|
|
|
|
|
spawnVolley(shot: VolleyShot): void {
|
|
|
|
|
this._volleys.push(shot);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 推进穿透与落地。
|
|
|
|
|
* @param dt 逻辑秒
|
|
|
|
|
*/
|
|
|
|
|
tick(dt: number): void {
|
|
|
|
|
const session = this._session;
|
|
|
|
|
if (!session) return;
|
|
|
|
|
for (let i = this._rays.length - 1; i >= 0; i--) {
|
|
|
|
|
const d = this._rays[i];
|
|
|
|
|
const step = d.speed * dt;
|
|
|
|
|
d.x += d.ux * step;
|
|
|
|
|
d.y += d.uy * step;
|
|
|
|
|
d.left -= dt;
|
|
|
|
|
const consumed = this._hitRay(session, d, step * 0.5);
|
|
|
|
|
if (consumed || d.left <= 0) this._rays.splice(i, 1);
|
|
|
|
|
}
|
|
|
|
|
for (let i = this._volleys.length - 1; i >= 0; i--) {
|
|
|
|
|
const d = this._volleys[i];
|
|
|
|
|
d.wait -= dt;
|
|
|
|
|
if (d.wait > 0) continue;
|
|
|
|
|
this._landVolley(session, d);
|
|
|
|
|
this._volleys.splice(i, 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 连发落地:可伤则结算;无论是否结算都回收表现箭。
|
|
|
|
|
* @param session 战斗会话
|
|
|
|
|
* @param d 在飞箭
|
|
|
|
|
*/
|
|
|
|
|
private _landVolley(session: BattleSession, d: VolleyShot): void {
|
|
|
|
|
if (session.world.isAlive(d.caster) && this._canHurt(session, d.target)) {
|
|
|
|
|
this._deal(session, d.caster, d.target, d.abilityId, d.scale, d.buff1, d.buff2);
|
|
|
|
|
}
|
|
|
|
|
this._invokeVolleyLand(d);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 触发并清空连发落地回收。
|
|
|
|
|
* @param d 弹道
|
|
|
|
|
*/
|
|
|
|
|
private _invokeVolleyLand(d: VolleyShot): void {
|
|
|
|
|
const onLand = d.onLand;
|
|
|
|
|
d.onLand = undefined;
|
|
|
|
|
onLand?.();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 散射体盒内敌方各伤一次(Cocos 空间,与画面箭一致)。
|
|
|
|
|
* 非穿透(atk_type=17)首中即结算并消耗,触发 onConsume 回收表现箭。
|
|
|
|
|
* @param session 战斗会话
|
|
|
|
|
* @param d 在飞箭
|
|
|
|
|
* @param extraLen 本帧步进半长,防穿透漏检
|
|
|
|
|
* @returns 箭是否被消耗(非穿透命中)
|
|
|
|
|
*/
|
|
|
|
|
private _hitRay(session: BattleSession, d: ScatterRay, extraLen: number): boolean {
|
|
|
|
|
if (!session.world.isAlive(d.caster)) return false;
|
|
|
|
|
const casterTeam = session.world.getComponent(d.caster, TeamComp);
|
|
|
|
|
const logical = SkillPresentationEnv.toLogical(d.caster, d.x, d.y);
|
|
|
|
|
if (!logical) {
|
|
|
|
|
console.log('[BowAtkHost] 散射箭无法映射逻辑格', d.caster);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const cellSize = Math.max(1, session.grid.cellSize);
|
|
|
|
|
const halfBody = cellSize * 0.5;
|
|
|
|
|
const cell = session.grid.worldToCell(logical.x, logical.y);
|
|
|
|
|
const radiusCells = (d.halfLen + extraLen + halfBody) / cellSize + 2;
|
|
|
|
|
const ids = session.spatial.queryRadius(cell, radiusCells);
|
|
|
|
|
for (let i = 0; i < ids.length; i++) {
|
|
|
|
|
const id = ids[i];
|
|
|
|
|
if (id === d.caster) continue;
|
|
|
|
|
if (this._hasHit(d.hitIds, id)) continue;
|
|
|
|
|
if (!this._canHurt(session, id)) continue;
|
|
|
|
|
if (!BattleTeamId.isHostile(casterTeam, session.world.getComponent(id, TeamComp))) continue;
|
|
|
|
|
const pos = SkillPresentationEnv.getTargetWorldPos(id);
|
|
|
|
|
if (!pos) continue;
|
|
|
|
|
const rx = pos.x - d.x;
|
|
|
|
|
const ry = pos.y - d.y;
|
|
|
|
|
const lx = rx * d.ux + ry * d.uy;
|
|
|
|
|
const ly = rx * d.uy - ry * d.ux;
|
|
|
|
|
if (Math.abs(lx) > d.halfLen + extraLen + halfBody) continue;
|
|
|
|
|
if (Math.abs(ly) > d.halfWid + halfBody) continue;
|
|
|
|
|
d.hitIds.push(id);
|
|
|
|
|
this._deal(session, d.caster, id, d.abilityId, d.scale, d.buff1, d.buff2);
|
|
|
|
|
if (!d.pierce) {
|
|
|
|
|
const onConsume = d.onConsume;
|
|
|
|
|
d.onConsume = undefined;
|
|
|
|
|
onConsume?.();
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 移除已消耗的在飞箭。
|
|
|
|
|
* @param ray 弹道
|
|
|
|
|
*/
|
|
|
|
|
private _removeRay(ray: ScatterRay): void {
|
|
|
|
|
const idx = this._rays.indexOf(ray);
|
|
|
|
|
if (idx >= 0) this._rays.splice(idx, 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 造成普攻伤害(可暴击、走 atk_buff)。
|
|
|
|
|
* @param session 战斗会话
|
|
|
|
|
* @param caster 施法者
|
|
|
|
|
* @param target 目标
|
|
|
|
|
* @param abilityId Ability id
|
|
|
|
|
* @param scale 伤害倍率
|
|
|
|
|
* @param buff1 atk_buff1
|
|
|
|
|
* @param buff2 atk_buff2
|
|
|
|
|
*/
|
|
|
|
|
private _deal(session: BattleSession, caster: EntityId, target: EntityId, abilityId: string, scale: number, buff1: string, buff2: string): void {
|
|
|
|
|
const ctx = createAbilityContext(session, caster, abilityId, [target]);
|
|
|
|
|
session.damage.dealToTarget(caster, target, {
|
|
|
|
|
scale,
|
|
|
|
|
allowCrit: true,
|
|
|
|
|
skillAtkBuff1: buff1,
|
|
|
|
|
skillAtkBuff2: buff2,
|
|
|
|
|
}, ctx);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 目标是否可受伤。
|
|
|
|
|
* @param session 战斗会话
|
|
|
|
|
* @param id 实体
|
|
|
|
|
*/
|
|
|
|
|
private _canHurt(session: BattleSession, id: EntityId): boolean {
|
|
|
|
|
if (!session.world.isAlive(id)) return false;
|
|
|
|
|
const hp = session.world.getComponent(id, HealthComp);
|
|
|
|
|
if (!hp || !(hp.hp > 0)) return false;
|
|
|
|
|
const move = session.world.getComponent(id, MovementComp);
|
|
|
|
|
if (move?.spawnProtected || isYanSoulClaimed(move) || isBajiaoBlowRetreating(move)) return false;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 是否已命中。
|
|
|
|
|
* @param ids 已命中列表
|
|
|
|
|
* @param id 实体
|
|
|
|
|
*/
|
|
|
|
|
private _hasHit(ids: EntityId[], id: EntityId): boolean {
|
|
|
|
|
for (let i = 0; i < ids.length; i++) {
|
|
|
|
|
if (ids[i] === id) return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let _host: BowAtkHost | null = null;
|
|
|
|
|
|
|
|
|
|
/** 全局弓箭弹道宿主 */
|
|
|
|
|
export function getBowAtkHost(): BowAtkHost {
|
|
|
|
|
if (!_host) _host = new BowAtkHost();
|
|
|
|
|
return _host;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 测试用重置 */
|
|
|
|
|
export function resetBowAtkHostForTests(): void {
|
|
|
|
|
_host = null;
|
|
|
|
|
}
|