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.
247 lines
8.7 KiB
247 lines
8.7 KiB
import { director, Director, game, Node, UITransform } from 'cc';
|
|
import { createMembershipSet, membershipAdd } from '../../../mx/tools/MembershipSet';
|
|
import type { EntityId } from 'db://assets/mx/module/battle';
|
|
import { Monster } from '../../ui/BattleGame/Monster';
|
|
import { SkillPresentationEnv } from './SkillPresentationEnv';
|
|
import type { SkillPresentationContext } from './SkillPresentationTypes';
|
|
|
|
/**
|
|
* @deprecated 新技能管线伤害归逻辑层,勿再以本类作为唯一命中源。
|
|
* 遗留穿透/转刀表现可暂时调用;新 SkillAbilityBuilder 技能应 ctx.hit()/finish()。
|
|
*
|
|
* 历史说明:表现体 AABB ∩ 怪盒 → dealDamage。
|
|
*/
|
|
|
|
/** 同 cast 对同怪只伤一次 */
|
|
export interface SkillCollisionOncePolicy {
|
|
mode: 'once';
|
|
onDealDamage?: (entityId: EntityId) => void;
|
|
scale?: number;
|
|
damageType?: string;
|
|
}
|
|
|
|
/** 可穿透:总命中次数上限;同一 bind 周期内每怪只伤一次 */
|
|
export interface SkillCollisionPiercePolicy {
|
|
mode: 'pierce';
|
|
maxHits?: number;
|
|
/** @deprecated 用 dispose 后重新 bind 实现「去程/回程各伤一次」,勿设为 true(会取消去重) */
|
|
hitSameTarget?: boolean;
|
|
alreadyHit?: EntityId[];
|
|
onDealDamage?: (entityId: EntityId) => void;
|
|
scale?: number;
|
|
damageType?: string;
|
|
}
|
|
|
|
/** 持续重叠按间隔重复伤害 */
|
|
export interface SkillCollisionIntervalPolicy {
|
|
mode: 'interval';
|
|
intervalSec: number;
|
|
onDealDamage?: (entityId: EntityId) => void;
|
|
scale?: number;
|
|
damageType?: string;
|
|
}
|
|
|
|
export type SkillCollisionPolicy =
|
|
| SkillCollisionOncePolicy
|
|
| SkillCollisionPiercePolicy
|
|
| SkillCollisionIntervalPolicy;
|
|
|
|
interface Aabb {
|
|
minX: number;
|
|
minY: number;
|
|
maxX: number;
|
|
maxY: number;
|
|
}
|
|
|
|
export class SkillCollisionHit {
|
|
private static readonly _active: SkillCollisionHit[] = [];
|
|
private static _hooked = false;
|
|
|
|
private static _ensureHook(): void {
|
|
if (SkillCollisionHit._hooked) return;
|
|
SkillCollisionHit._hooked = true;
|
|
director.on(Director.EVENT_AFTER_UPDATE, SkillCollisionHit._afterUpdate);
|
|
}
|
|
|
|
private static _afterUpdate(): void {
|
|
if (SkillCollisionHit._active.length === 0) return;
|
|
const battle = gg.game?.CurentBattle as { TimeScale?: number } | null | undefined;
|
|
if ((battle?.TimeScale ?? 1) <= 0) return;
|
|
for (let i = SkillCollisionHit._active.length - 1; i >= 0; i--) {
|
|
SkillCollisionHit._active[i]._onAfterUpdate();
|
|
}
|
|
}
|
|
|
|
private static _register(hit: SkillCollisionHit): void {
|
|
SkillCollisionHit._ensureHook();
|
|
if (SkillCollisionHit._active.indexOf(hit) < 0) {
|
|
SkillCollisionHit._active.push(hit);
|
|
}
|
|
}
|
|
|
|
private static _unregister(hit: SkillCollisionHit): void {
|
|
const idx = SkillCollisionHit._active.indexOf(hit);
|
|
if (idx >= 0) SkillCollisionHit._active.splice(idx, 1);
|
|
}
|
|
|
|
private _node: Node | null = null;
|
|
private _ctx: SkillPresentationContext | null = null;
|
|
private _policy: SkillCollisionPolicy | null = null;
|
|
private _hitTargets = createMembershipSet<EntityId>();
|
|
private _hitCount = 0;
|
|
private _overlapElapsed = new Map<EntityId, number>();
|
|
/** 本帧仍重叠的实体 */
|
|
private _overlapping = createMembershipSet<EntityId>();
|
|
/** 技能体世界 AABB */
|
|
private _selfAabb: Aabb = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
|
/** 怪体世界 AABB */
|
|
private _otherAabb: Aabb = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
|
private _disposed = false;
|
|
|
|
bind(node: Node, ctx: SkillPresentationContext, policy: SkillCollisionPolicy): this {
|
|
this.dispose();
|
|
this._disposed = false;
|
|
this._node = node;
|
|
this._ctx = ctx;
|
|
this._policy = policy;
|
|
this._hitTargets.clear();
|
|
this._hitCount = 0;
|
|
this._overlapElapsed.clear();
|
|
|
|
if (policy.mode === 'pierce') {
|
|
for (const id of policy.alreadyHit ?? []) {
|
|
membershipAdd(this._hitTargets, id);
|
|
}
|
|
}
|
|
|
|
SkillCollisionHit._register(this);
|
|
this._onAfterUpdate();
|
|
return this;
|
|
}
|
|
|
|
dispose(): void {
|
|
if (this._disposed && !this._node) return;
|
|
this._disposed = true;
|
|
SkillCollisionHit._unregister(this);
|
|
this._node = null;
|
|
this._ctx = null;
|
|
this._policy = null;
|
|
this._hitTargets.clear();
|
|
this._overlapElapsed.clear();
|
|
this._overlapping.clear();
|
|
this._hitCount = 0;
|
|
}
|
|
|
|
private _onAfterUpdate(): void {
|
|
if (this._disposed) {
|
|
SkillCollisionHit._unregister(this);
|
|
return;
|
|
}
|
|
const node = this._node;
|
|
const ctx = this._ctx;
|
|
const policy = this._policy;
|
|
if (!node?.isValid || !ctx?.isAlive || !policy) return;
|
|
|
|
const selfBox = this._selfAabb;
|
|
if (!fillWorldAabb(node, selfBox)) return;
|
|
|
|
const overlapping = this._overlapping;
|
|
overlapping.clear();
|
|
const otherBox = this._otherAabb;
|
|
SkillPresentationEnv.forEachMonsterNode((entityId, monsterNode) => {
|
|
if (!SkillPresentationEnv.isEntityAlive(entityId)) return;
|
|
if (SkillPresentationEnv.isYanCaught(entityId)) return;
|
|
if (SkillPresentationEnv.isBajiaoBlowRetreating(entityId)) return;
|
|
const monster = monsterNode.getComponent(Monster);
|
|
if (!monster || monster.IsDead) return;
|
|
if (!fillWorldAabb(monsterNode, otherBox) || !aabbOverlap(selfBox, otherBox)) return;
|
|
membershipAdd(overlapping, entityId);
|
|
this._onOverlap(entityId, policy);
|
|
});
|
|
|
|
if (policy.mode === 'interval') {
|
|
this._overlapElapsed.forEach((_elapsed, id) => {
|
|
if (!overlapping.has(id)) this._overlapElapsed.delete(id);
|
|
});
|
|
const battle = gg.game?.CurentBattle as { TimeScale?: number } | null | undefined;
|
|
const dt = (game.deltaTime || 1 / 60) * Math.max(0, battle?.TimeScale ?? 1);
|
|
const interval = Math.max(0.01, policy.intervalSec);
|
|
this._overlapElapsed.forEach((elapsed, entityId) => {
|
|
if (!overlapping.has(entityId)) return;
|
|
const next = elapsed + dt;
|
|
if (next >= interval) {
|
|
this._tryDeal(entityId, policy.scale, policy.damageType);
|
|
this._overlapElapsed.set(entityId, next - interval);
|
|
} else {
|
|
this._overlapElapsed.set(entityId, next);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
private _onOverlap(entityId: EntityId, policy: SkillCollisionPolicy): void {
|
|
if (policy.mode === 'interval') {
|
|
if (!this._overlapElapsed.has(entityId)) {
|
|
this._overlapElapsed.set(entityId, 0);
|
|
this._tryDeal(entityId, policy.scale, policy.damageType);
|
|
}
|
|
return;
|
|
}
|
|
if (policy.mode === 'once') {
|
|
if (this._hitTargets.has(entityId)) return;
|
|
if (this._tryDeal(entityId, policy.scale, policy.damageType)) {
|
|
membershipAdd(this._hitTargets, entityId);
|
|
}
|
|
return;
|
|
}
|
|
// pierce:同一 bind 周期内每怪只伤一次(回旋回程须 dispose 后重新 bind)
|
|
if (policy.maxHits != null && this._hitCount >= policy.maxHits) return;
|
|
if (this._hitTargets.has(entityId)) return;
|
|
if (this._tryDeal(entityId, policy.scale, policy.damageType)) {
|
|
membershipAdd(this._hitTargets, entityId);
|
|
this._hitCount++;
|
|
}
|
|
}
|
|
|
|
private _tryDeal(
|
|
entityId: EntityId,
|
|
scale?: number,
|
|
damageType?: string,
|
|
): boolean {
|
|
const ctx = this._ctx;
|
|
const policy = this._policy;
|
|
if (!ctx?.isAlive) return false;
|
|
const ok = ctx.dealDamage(entityId, { scale, damageType });
|
|
if (ok) policy?.onDealDamage?.(entityId);
|
|
return ok;
|
|
}
|
|
}
|
|
|
|
function aabbOverlap(a: Aabb, b: Aabb): boolean {
|
|
return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY;
|
|
}
|
|
|
|
/**
|
|
* 写入节点 UITransform 世界 AABB;无变换则用点扩 40。
|
|
* @param node 节点
|
|
* @param out 输出
|
|
* @returns 节点无效时 false
|
|
*/
|
|
function fillWorldAabb(node: Node, out: Aabb): boolean {
|
|
if (!node?.isValid) return false;
|
|
const ut = node.getComponent(UITransform);
|
|
if (!ut) {
|
|
const p = node.worldPosition;
|
|
out.minX = p.x - 40;
|
|
out.minY = p.y - 40;
|
|
out.maxX = p.x + 40;
|
|
out.maxY = p.y + 40;
|
|
return true;
|
|
}
|
|
const rect = ut.getBoundingBoxToWorld();
|
|
out.minX = rect.x;
|
|
out.minY = rect.y;
|
|
out.maxX = rect.x + rect.width;
|
|
out.maxY = rect.y + rect.height;
|
|
return true;
|
|
}
|
|
|