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/mx/module/battle/combat/ProjectileSystem.ts

205 lines
8.6 KiB

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<EntityId>();
}
}
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));
}
}