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.
206 lines
8.6 KiB
206 lines
8.6 KiB
|
3 weeks ago
|
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));
|
||
|
|
}
|
||
|
|
}
|