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/combat/BowAtkRuntime.ts

325 lines
10 KiB

3 weeks ago
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';
/** 弓箭飞行像素/秒(逻辑与表现共用) */
3 days ago
export const BOW_ATK_ARROW_SPEED = 600;
3 weeks ago
/** 散射箭飞行距离 = 初始射程 × 此倍数 */
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[];
2 weeks ago
/** 是否穿透:12 穿透;17 不穿透(首中即消耗) */
pierce: boolean;
/** 非穿透命中消耗回调(回收表现箭);逻辑命中时刻触发 */
onConsume?: () => void;
3 weeks ago
}
/** 连发箭:落地伤锁定目标 */
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);
}
/**
2 weeks ago
* 生成一支散射箭(12 穿透 / 17 不穿透)。
3 weeks ago
* @param ray 弹道
*/
spawnRay(ray: ScatterRay): void {
this._rays.push(ray);
const session = this._session;
2 weeks ago
if (!session) return;
if (this._hitRay(session, ray, 0)) this._removeRay(ray);
3 weeks ago
}
/**
* 生成一发落地结算的连发箭。
* @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;
2 weeks ago
const consumed = this._hitRay(session, d, step * 0.5);
if (consumed || d.left <= 0) this._rays.splice(i, 1);
3 weeks ago
}
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 空间,与画面箭一致)。
2 weeks ago
* 非穿透(atk_type=17)首中即结算并消耗,触发 onConsume 回收表现箭。
3 weeks ago
* @param session 战斗会话
* @param d 在飞箭
* @param extraLen 本帧步进半长,防穿透漏检
2 weeks ago
* @returns 箭是否被消耗(非穿透命中)
3 weeks ago
*/
2 weeks ago
private _hitRay(session: BattleSession, d: ScatterRay, extraLen: number): boolean {
if (!session.world.isAlive(d.caster)) return false;
3 weeks ago
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);
2 weeks ago
return false;
3 weeks ago
}
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);
2 weeks ago
if (!d.pierce) {
const onConsume = d.onConsume;
d.onConsume = undefined;
onConsume?.();
return true;
}
3 weeks ago
}
2 weeks ago
return false;
}
/**
* 移除已消耗的在飞箭。
* @param ray 弹道
*/
private _removeRay(ray: ScatterRay): void {
const idx = this._rays.indexOf(ray);
if (idx >= 0) this._rays.splice(idx, 1);
3 weeks ago
}
/**
* 造成普攻伤害(可暴击、走 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;
}