import type { World, EntityId } from 'db://assets/mx/module/ecs'; import type { ISystem } from 'db://assets/mx/module/ecs'; import type { EffectSpec } from '../config/BattleConfigTypes'; import { ProjectileComp, TransformComp, TeamComp, HealthComp, MovementComp, isBajiaoBlowRetreating, isYanSoulClaimed } from '../ecs/Components'; import { BattleTeamId } from '../ecs/BattleTeamId'; import type { GridMap } from '../battlefield/GridMap'; import type { SpatialGrid } from '../battlefield/SpatialGrid'; import type { EffectExecutor } from './EffectExecutor'; import type { BattleServices } from './BattleServices'; import { createAbilityContext } from './AbilityContext'; import { BattleEventFactory } from '../events/BattleEvents'; import type { BattleEventBus } from '../events/BattleEventBus'; import { createMembershipSet, membershipAdd, type MembershipSet } from 'db://assets/mx/tools/MembershipSet'; /** 投射物生成选项 */ export interface ProjectileOptions { /** 飞行模式 */ mode?: 'homing' | 'linear'; /** 直线方向(格,无需归一化) */ direction?: { dr: number; dc: number }; /** 直线飞行总距离(像素;内部换算为格推进) */ travel?: number; /** 穿透目标数(null=无限穿透) */ pierce?: number | null; /** 表现层资源名 */ prefab?: string; /** 伤害缩放透传给 hitEffects */ damageScale?: number; } /** 投射物系统 */ export class ProjectileSystem implements ISystem { readonly priority = 40; enabled = true; constructor( private readonly _world: World, private readonly _grid: GridMap, private readonly _spatial: SpatialGrid, private readonly _effects: EffectExecutor, private readonly _services: BattleServices, private readonly _events: BattleEventBus, ) {} /** 本拍待销毁弹道 */ private readonly _toRemove: EntityId[] = []; /** 直线弹道本拍命中缓冲 */ private readonly _hits: EntityId[] = []; /** 空间索引格子 scratch */ private readonly _cell = { row: 0, col: 0 }; /** * @param caster 发射者实体 ID * @param target 目标实体 ID(linear 模式仅用于初始方向参考,可传 INVALID) * @param hitEffects 命中效果列表 * @param speed 飞行速度(格/秒) * @param damageScale 伤害缩放,默认 1 * @param damageType 伤害类型 * @param options 额外选项(mode/direction/travel/pierce/prefab) * @returns 投射物实体 ID */ spawn( caster: EntityId, target: EntityId, hitEffects: EffectSpec[], speed: number, damageScale = 1, damageType?: string, options?: ProjectileOptions, ): EntityId { const world = this._world; const id = world.spawn(); const proj = new ProjectileComp(caster, speed, target, hitEffects, damageScale, damageType); proj.damageScale = damageScale; if (options) { proj.mode = options.mode ?? 'homing'; proj.prefab = options.prefab; if (options.mode === 'linear') { if (options.direction) { const len = Math.sqrt(options.direction.dr ** 2 + options.direction.dc ** 2) || 1; proj.direction = { dr: options.direction.dr / len, dc: options.direction.dc / len }; } proj.travelRemaining = (options.travel ?? (10 * this._grid.cellSize)) / Math.max(1, this._grid.cellSize); proj.pierceRemaining = options.pierce ?? 1; proj.hitSet = createMembershipSet(); } } world.addComponent(id, proj); const casterTf = world.getComponent(caster, TransformComp); if (casterTf) { world.addComponent(id, new TransformComp(casterTf.row, casterTf.col)); this._spatial.updateEntity(id, { row: casterTf.row, col: casterTf.col }); } this._events.emit(BattleEventFactory.projectileSpawned(id, caster, proj.prefab)); return id; } /** @param dt 帧间隔(秒) */ tick(dt: number): void { const world = this._world; const toRemove = this._toRemove; toRemove.length = 0; world.view2(ProjectileComp, TransformComp).forEach((id, proj, tf) => { if (proj.mode === 'linear') { this._tickLinear(id, proj, tf, dt, toRemove); } else { this._tickHoming(id, proj, tf, dt, toRemove); } }); for (const id of toRemove) { this._spatial.removeEntity(id); this._world.destroy(id); } } /** 制导追踪 */ private _tickHoming(id: EntityId, proj: ProjectileComp, tf: TransformComp, dt: number, toRemove: EntityId[]): void { const world = this._world; const targetTf = world.getComponent(proj.target, TransformComp); if (!targetTf) { toRemove.push(id); return; } const dx = targetTf.col - tf.col; const dy = targetTf.row - tf.row; const dist = Math.sqrt(dx * dx + dy * dy); const move = proj.speed * dt / Math.max(this._grid.cellSize, 1); if (dist <= move || dist < 0.15) { // 出生保护 / 阎王拘魂归属:弹道消散,不结算命中 const targetMove = world.getComponent(proj.target, MovementComp); if (targetMove?.spawnProtected || isYanSoulClaimed(targetMove) || isBajiaoBlowRetreating(targetMove)) { toRemove.push(id); return; } this._hit(id, proj, [proj.target]); toRemove.push(id); } else { tf.col += (dx / dist) * move; tf.row += (dy / dist) * move; this._cell.row = Math.round(tf.row); this._cell.col = Math.round(tf.col); this._spatial.updateEntity(id, this._cell); } } /** 直线穿透 */ private _tickLinear(id: EntityId, proj: ProjectileComp, tf: TransformComp, dt: number, toRemove: EntityId[]): void { const dir = proj.direction; if (!dir) { toRemove.push(id); return; } const move = proj.speed * dt / Math.max(this._grid.cellSize, 1); // speed 为像素/秒 → 格 tf.row += dir.dr * move; tf.col += dir.dc * move; proj.travelRemaining = (proj.travelRemaining ?? 0) - move; this._cell.row = Math.round(tf.row); this._cell.col = Math.round(tf.col); this._spatial.updateEntity(id, this._cell); // 命中当前格子上的敌对实体(穿透;不含己方口/营召唤) const casterTeam = this._world.getComponent(proj.caster, TeamComp); const occupants = this._spatial.queryRadius(this._cell, 0.5); const hits = this._hits; hits.length = 0; for (const occ of occupants) { if (occ === proj.caster) continue; if (proj.hitSet?.has(occ)) continue; const team = this._world.getComponent(occ, TeamComp); if (!BattleTeamId.isHostile(casterTeam, team)) continue; if (!this._world.getComponent(occ, HealthComp)) continue; const occMove = this._world.getComponent(occ, MovementComp); if (occMove?.spawnProtected || isYanSoulClaimed(occMove) || isBajiaoBlowRetreating(occMove)) continue; hits.push(occ); proj.hitSet?.set(occ, true); } if (hits.length) { this._hit(id, proj, hits); if (proj.pierceRemaining != null) { proj.pierceRemaining -= hits.length; if (proj.pierceRemaining <= 0) toRemove.push(id); } } if (proj.travelRemaining != null && proj.travelRemaining <= 0 && !toRemove.includes(id)) { toRemove.push(id); } } /** 执行命中效果(damageScale 透传)+ 派发事件 */ private _hit(projectile: EntityId, proj: ProjectileComp, targets: EntityId[]): void { const scaledEffects = proj.damageScale !== 1 ? proj.hitEffects.map(s => s.params?.scale != null ? { ...s, params: { ...s.params, scale: (s.params.scale as number) * proj.damageScale } } : s) : proj.hitEffects; for (const targetId of targets) { const ctx = createAbilityContext(this._services, proj.caster, 'projectile_hit', [targetId]); for (const spec of scaledEffects) { this._effects.execute(ctx, spec); } } this._events.emit(BattleEventFactory.projectileHit(projectile, proj.caster, targets[0], proj.hitEffects)); } }