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 = 1280; /** 散射箭飞行距离 = 初始射程 × 此倍数 */ 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[]; } /** 连发箭:落地伤锁定目标 */ 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); } /** * 生成一支散射穿透箭。 * @param ray 弹道 */ spawnRay(ray: ScatterRay): void { this._rays.push(ray); const session = this._session; if (session) this._hitRay(session, ray, 0); } /** * 生成一发落地结算的连发箭。 * @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; this._hitRay(session, d, step * 0.5); if (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 空间,与画面箭一致)。 * @param session 战斗会话 * @param d 在飞箭 * @param extraLen 本帧步进半长,防穿透漏检 */ private _hitRay(session: BattleSession, d: ScatterRay, extraLen: number): void { if (!session.world.isAlive(d.caster)) return; 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; } 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); } } /** * 造成普攻伤害(可暴击、走 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; }