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.
1342 lines
49 KiB
1342 lines
49 KiB
import { Button, Label, Node, Sorting2D, Tween, tween, UIOpacity, game, v3, sp } from 'cc';
|
|
import { createMembershipSet, membershipAdd, membershipFromArray, membershipDedupeArray, membershipToArray, type MembershipSet, type ReadonlyMembershipSet } from '../../../mx/tools/MembershipSet';
|
|
import {
|
|
BattlePhase,
|
|
BattleTeamId,
|
|
EntityTag,
|
|
INVALID_ENTITY_ID,
|
|
MovementComp,
|
|
TagComp,
|
|
TeamComp,
|
|
TransformComp,
|
|
UnitMetaComp,
|
|
isYanSoulClaimed,
|
|
type BattleSession,
|
|
type Cell,
|
|
type EntityId,
|
|
} from 'db://assets/mx/module/battle';
|
|
import { BattleUiPauseGate } from '../BattleUiPauseGate';
|
|
import { BattleAudio } from '../audio/BattleAudio';
|
|
import { GBundle, GPath } from '../../game/ConfigRes';
|
|
import { syncMovementNode } from '../bridge/CocosBattleMovementBridge';
|
|
import type { MapManager } from '../map/legacy/MapManager';
|
|
import type { MapGrid } from '../map/legacy/MapGrid';
|
|
import { collectBajiaoCampStands, resolveAttackStandGridForCamp } from './tangSengMap';
|
|
import { isMechanismParatroopUnit, isMechanismUnit } from '../mechanism/MechanismServices';
|
|
import { BattleSpinePlayback } from '../presentation/BattleSpinePlayback';
|
|
|
|
const ICON_PREFAB = '芭蕉扇图标';
|
|
const FAN_PREFAB = '芭蕉扇';
|
|
const WIND_PREFAB = '芭蕉扇-旋风';
|
|
/** 唐僧求救距离(格);常量表无此行 */
|
|
const BAJIAO_HELP_DISTANCE = 5;
|
|
/** 无威胁后隐藏气泡延迟(秒) */
|
|
const BAJIAO_HIDE_DELAY_SEC = 2;
|
|
/** 旋风回退最大格数 */
|
|
const BAJIAO_RETREAT_CELLS = 10;
|
|
const ANIM_FAN = '芭蕉扇';
|
|
const ANIM_WIND = '旋风';
|
|
/** 点气泡后延迟多久再出旋风预制体(秒) */
|
|
const WIND_SPAWN_DELAY = 0.8;
|
|
/** 观看广告期间暂停 */
|
|
const AD_PAUSE_TOKEN = 'ad:bajiaoFan';
|
|
|
|
/** 气泡相对攻击点沿怪物移动路线回退格数 */
|
|
const ICON_BEHIND_STEPS = 1;
|
|
/** 第 5 关:图标固定为唐僧站立格向左 1 格 */
|
|
const CHAPTER5_BAJIAO_ICON_ID = 5;
|
|
/** 旋风特效相对攻击站立格回退格数 */
|
|
const FX_BEHIND_STEPS = 1;
|
|
/** 后退速度:格/秒 */
|
|
const RETREAT_CELLS_PER_SEC = 6;
|
|
/** 旋风距怪 ≤ 此格数时,该怪才开始后移 */
|
|
const WIND_CATCH_CELLS = 1;
|
|
|
|
/** 气泡显示时持续呼吸;单程时长(秒) */
|
|
const BREATH_HALF_SEC = 0.35;
|
|
const BREATH_SCALE = 1.12;
|
|
|
|
interface WindSpawnPlan {
|
|
port: number;
|
|
path: Cell[];
|
|
fxStartWorld: Readonly<{ x: number; y: number; z: number }>;
|
|
}
|
|
|
|
interface PortWindState {
|
|
port: number;
|
|
windEntityId: EntityId;
|
|
windNode: Node;
|
|
path: Cell[];
|
|
sweeping: boolean;
|
|
}
|
|
|
|
/** 单阵营芭蕉扇气泡(多攻击目标时每营一个,次数共享) */
|
|
interface BajiaoCampSlot {
|
|
campAreaId: number;
|
|
standGrid: MapGrid;
|
|
node: Node;
|
|
numLabel: Label | null;
|
|
visible: boolean;
|
|
noThreatSec: number;
|
|
baseScale: number;
|
|
onClick: () => void;
|
|
}
|
|
|
|
/**
|
|
* 芭蕉扇广告气泡 + 点击推回:
|
|
* - Const74 / 75 控制显隐;advertise_limit[6] 控制本关广告次数(各营图标共享)
|
|
* - 多攻击目标:每阵营独立气泡与旋风路径(出怪口 → 本营攻击点)
|
|
* - 旋风:每条出怪口进路全程吹到出怪口(与 Const76 无关)
|
|
* - 可吹对象:凡会攻击唐僧的怪(沿路前进、已在攻墙均可);不含白骨精等 mechanism_unit
|
|
* - 怪物:仅被风碰到时,沿进路后退 Const76 格(每只怪单独算停格);
|
|
* 若距出怪口不足 Const76 格则退到出怪口并进放行队列
|
|
* - 飞禽:被风碰到后沿进路退回落地点,到位后恢复寻路
|
|
*/
|
|
export class BajiaoFanIconPresenter {
|
|
private _campSlots: BajiaoCampSlot[] = [];
|
|
private _map: MapManager | null = null;
|
|
private _session: BattleSession | null = null;
|
|
|
|
private _effectPlaying = false;
|
|
private _effectCampAreaId = 0;
|
|
private _adPending = false;
|
|
/** 芭蕉扇表现(播一次后回收) */
|
|
private _fanNode: Node | null = null;
|
|
/** 各出怪口旋风:实体 + 表现 */
|
|
private _portWinds: PortWindState[] = [];
|
|
private _retreatIds: EntityId[] = [];
|
|
/** 后退路径终点为出怪口时:进出生口放行队列(距洞口 ≤ Const76 格的怪) */
|
|
private _requeueAfterRetreat = createMembershipSet<EntityId>();
|
|
private _retreatSpeedPx = 0;
|
|
private _retreatPathfinderBuilt = false;
|
|
/** 是否仍有旋风在沿路扫怪 */
|
|
private _awaitingWindSweep = false;
|
|
/** >0:等待生成旋风;-1 未在倒计时 */
|
|
private _windSpawnTimer = -1;
|
|
private _pendingWindPlans: WindSpawnPlan[] | null = null;
|
|
private readonly _fxStartWorld = v3();
|
|
|
|
bind(map: MapManager, session: BattleSession): void {
|
|
this.unbind();
|
|
if (gg.game.CurentBattle?.session?.topology?.enablePatrolLoop) {
|
|
return;
|
|
}
|
|
|
|
const camps = collectBajiaoCampStands(map, session);
|
|
if (!camps.length) {
|
|
console.error('[BajiaoFan] 攻击站立格缺失');
|
|
return;
|
|
}
|
|
|
|
const slots: BajiaoCampSlot[] = [];
|
|
for (const camp of camps) {
|
|
const behind = isChapter5BajiaoIconSpecial() && camps.length === 1
|
|
? (resolveChapter5IconGrid(map, camp.standGrid)
|
|
?? resolveIconBehindGridForCamp(map, session, camp.standGrid, ICON_BEHIND_STEPS, camp.campAreaId))
|
|
: resolveIconBehindGridForCamp(map, session, camp.standGrid, ICON_BEHIND_STEPS, camp.campAreaId);
|
|
if (!behind) {
|
|
console.error(
|
|
'[BajiaoFan] 攻击点回退格解析失败',
|
|
`camp=${camp.campAreaId}`,
|
|
camp.standGrid.Row,
|
|
camp.standGrid.Col,
|
|
ICON_BEHIND_STEPS,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const node = gg.res.getNode(GPath.ObstaclePrefab(''), ICON_PREFAB, GBundle.BattleMap);
|
|
if (!node?.isValid) {
|
|
console.error('[BajiaoFan] 预制体缺失', ICON_PREFAB);
|
|
continue;
|
|
}
|
|
|
|
node.parent = map.node;
|
|
node.worldPosition = behind.node.worldPosition.clone();
|
|
const baseScale = node.scale.x || 0.7;
|
|
node.setScale(baseScale, baseScale, 1);
|
|
node.active = false;
|
|
const sorting = node.getOrAddComponent(Sorting2D);
|
|
sorting.sortingLayer = 3;
|
|
sorting.sortingOrder = 3;
|
|
|
|
node.getOrAddComponent(Button);
|
|
const campAreaId = camp.campAreaId;
|
|
const onClick = (): void => this._onCampClick(campAreaId);
|
|
node.on(Node.EventType.TOUCH_END, onClick, this);
|
|
|
|
slots.push({
|
|
campAreaId,
|
|
standGrid: camp.standGrid,
|
|
node,
|
|
numLabel: node.getChildByName('num')?.getComponent(Label) ?? null,
|
|
visible: false,
|
|
noThreatSec: 0,
|
|
baseScale,
|
|
onClick,
|
|
});
|
|
}
|
|
|
|
if (!slots.length) {
|
|
console.error('[BajiaoFan] 无有效阵营气泡');
|
|
return;
|
|
}
|
|
|
|
this._campSlots = slots;
|
|
this._map = map;
|
|
this._session = session;
|
|
this._adPending = false;
|
|
this._refreshNumLabels();
|
|
}
|
|
|
|
unbind(): void {
|
|
this._abortEffect();
|
|
for (const slot of this._campSlots) {
|
|
this._stopSlotBreath(slot);
|
|
const node = slot.node;
|
|
if (node?.isValid) {
|
|
node.off(Node.EventType.TOUCH_END, slot.onClick, this);
|
|
gg.res.putNode(node);
|
|
}
|
|
}
|
|
this._campSlots = [];
|
|
if (this._adPending) {
|
|
this._adPending = false;
|
|
BattleUiPauseGate.release(AD_PAUSE_TOKEN);
|
|
}
|
|
this._map = null;
|
|
this._session = null;
|
|
}
|
|
|
|
update(dt: number): void {
|
|
this._tickRequeueAfterRetreat();
|
|
this._tickEffect(dt);
|
|
const session = this._session;
|
|
if (!session || this._campSlots.length === 0) return;
|
|
if (session.ended) {
|
|
this._setAllCampVisible(false);
|
|
return;
|
|
}
|
|
if (session.phase === BattlePhase.PAUSE) return;
|
|
if (session.phase !== BattlePhase.COMBAT) {
|
|
this._setAllCampVisible(false);
|
|
return;
|
|
}
|
|
if (this._effectPlaying || this._adPending) return;
|
|
if (!this._hasRemainingUses()) {
|
|
this._setAllCampVisible(false);
|
|
return;
|
|
}
|
|
|
|
const nearCells = BAJIAO_HELP_DISTANCE;
|
|
const hideDelaySec = BAJIAO_HIDE_DELAY_SEC;
|
|
|
|
for (const slot of this._campSlots) {
|
|
const inRange = hasEnemyWithinPathCellsForCamp(session, slot.campAreaId, nearCells);
|
|
if (inRange) {
|
|
slot.noThreatSec = 0;
|
|
this._setCampVisible(slot, true);
|
|
continue;
|
|
}
|
|
if (!slot.visible) {
|
|
slot.noThreatSec = 0;
|
|
continue;
|
|
}
|
|
slot.noThreatSec += dt;
|
|
if (slot.noThreatSec >= Math.max(0, hideDelaySec)) {
|
|
this._setCampVisible(slot, false);
|
|
slot.noThreatSec = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
private _onCampClick(campAreaId: number): void {
|
|
const slot = this._campSlots.find((s) => s.campAreaId === campAreaId);
|
|
if (!slot?.visible || this._effectPlaying || this._adPending) return;
|
|
const session = this._session;
|
|
if (!session || session.phase !== BattlePhase.COMBAT || session.ended) return;
|
|
const match = this._match();
|
|
if (!match?.canUseBajiaoFanAd()) return;
|
|
|
|
this._adPending = true;
|
|
BattleUiPauseGate.acquire(AD_PAUSE_TOKEN);
|
|
gg.sdk.showVideoAd((res) => {
|
|
if (!this._adPending) return;
|
|
this._adPending = false;
|
|
BattleUiPauseGate.release(AD_PAUSE_TOKEN);
|
|
if (game.isPaused()) {
|
|
game.resume();
|
|
}
|
|
if (!res) {
|
|
gg.ui.showToast('观看视频失败');
|
|
return;
|
|
}
|
|
const battle = this._match();
|
|
if (!battle || !this._session || this._session.ended) return;
|
|
if (!battle.canUseBajiaoFanAd()) return;
|
|
|
|
battle.IsWatchAd = true;
|
|
battle.consumeBajiaoFanAd();
|
|
this._refreshNumLabels();
|
|
this._setAllCampVisible(false);
|
|
for (const s of this._campSlots) s.noThreatSec = 0;
|
|
//gg.sdk.reportDY('inLevel', `${gg.game.getReportDYBattleLabel()}-AD-广告芭蕉扇`);
|
|
this._startEffect(campAreaId);
|
|
});
|
|
};
|
|
|
|
private _match(): {
|
|
IsWatchAd: boolean;
|
|
canUseBajiaoFanAd(): boolean;
|
|
consumeBajiaoFanAd(): void;
|
|
getBajiaoFanAdRemaining(): number;
|
|
bajiaoFanAdMax: number;
|
|
} | null {
|
|
return (gg.game?.CurentBattle as {
|
|
IsWatchAd: boolean;
|
|
canUseBajiaoFanAd(): boolean;
|
|
consumeBajiaoFanAd(): void;
|
|
getBajiaoFanAdRemaining(): number;
|
|
bajiaoFanAdMax: number;
|
|
} | null) ?? null;
|
|
}
|
|
|
|
private _hasRemainingUses(): boolean {
|
|
return this._match()?.canUseBajiaoFanAd() ?? false;
|
|
}
|
|
|
|
private _refreshNumLabels(): void {
|
|
const match = this._match();
|
|
const left = match?.getBajiaoFanAdRemaining() ?? 0;
|
|
const max = match?.bajiaoFanAdMax ?? 0;
|
|
const text = `${left}/${max}`;
|
|
for (const slot of this._campSlots) {
|
|
const label = slot.numLabel;
|
|
if (label?.isValid) label.string = text;
|
|
}
|
|
}
|
|
|
|
private _startEffect(campAreaId: number): void {
|
|
const session = this._session;
|
|
const map = this._map;
|
|
if (!session || !map?.gridsInitialized) return;
|
|
|
|
const slot = this._campSlots.find((s) => s.campAreaId === campAreaId);
|
|
const stand = slot?.standGrid ?? resolveAttackStandGridForCamp(map, session, campAreaId);
|
|
if (!stand) {
|
|
console.error('[BajiaoFan] 特效:攻击站立格缺失', `camp=${campAreaId}`);
|
|
return;
|
|
}
|
|
|
|
session.pathfinder.rebuild(session.topology);
|
|
const plans: WindSpawnPlan[] = [];
|
|
for (const port of collectSpawnPortsForCamp(session, campAreaId)) {
|
|
const spawnGrid = resolveSpawnGridByPort(map, session, port);
|
|
if (!spawnGrid) continue;
|
|
const fxGrid = resolveAttackPointBehindGrid(
|
|
map, session, stand, FX_BEHIND_STEPS, spawnGrid, campAreaId,
|
|
);
|
|
if (!fxGrid) continue;
|
|
const fxStart: Cell = { row: fxGrid.Row, col: fxGrid.Col };
|
|
const spawn: Cell = { row: spawnGrid.Row, col: spawnGrid.Col };
|
|
const windPath = buildWindPathToSpawn(session, fxStart, spawn);
|
|
if (windPath.length < 2) continue;
|
|
plans.push({
|
|
port,
|
|
path: windPath,
|
|
fxStartWorld: fxGrid.node.worldPosition.clone(),
|
|
});
|
|
}
|
|
if (plans.length === 0) {
|
|
console.error('[BajiaoFan] 特效:无有效出怪口旋风路径', `camp=${campAreaId}`);
|
|
return;
|
|
}
|
|
|
|
const fanNode = gg.res.getNode(GPath.ObstaclePrefab(''), FAN_PREFAB, GBundle.BattleMap);
|
|
if (!fanNode?.isValid) {
|
|
console.error('[BajiaoFan] 预制体缺失', FAN_PREFAB);
|
|
return;
|
|
}
|
|
|
|
this._effectPlaying = true;
|
|
this._effectCampAreaId = campAreaId;
|
|
BattleAudio.playBajiaoFanCast();
|
|
this._pendingWindPlans = plans;
|
|
this._windSpawnTimer = WIND_SPAWN_DELAY;
|
|
this._fxStartWorld.set(plans[0].fxStartWorld.x, plans[0].fxStartWorld.y, plans[0].fxStartWorld.z);
|
|
|
|
this._fanNode = fanNode;
|
|
fanNode.parent = map.node;
|
|
fanNode.worldPosition = this._fxStartWorld.clone();
|
|
fanNode.active = true;
|
|
const sorting = fanNode.getOrAddComponent(Sorting2D);
|
|
sorting.sortingLayer = 3;
|
|
sorting.sortingOrder = 4;
|
|
|
|
const skel = fanNode.getComponent(sp.Skeleton) ?? fanNode.getComponentInChildren(sp.Skeleton);
|
|
if (!skel) {
|
|
console.error('[BajiaoFan] 芭蕉扇缺少 Spine');
|
|
this._abortEffect();
|
|
return;
|
|
}
|
|
skel.setAnimationCacheMode(sp.SpineAnimationCacheMode.REALTIME);
|
|
BattleSpinePlayback.resetTracks(skel);
|
|
skel.setToSetupPose();
|
|
skel.timeScale = 1;
|
|
if (!skel.findAnimation(ANIM_FAN)) {
|
|
console.error('[BajiaoFan] 缺少动画', ANIM_FAN);
|
|
this._abortEffect();
|
|
return;
|
|
}
|
|
BattleSpinePlayback.play(skel, ANIM_FAN, {
|
|
loop: false,
|
|
onComplete: () => this._recycleFanNode(),
|
|
});
|
|
}
|
|
|
|
/** 延迟后按出怪口数量生成旋风并沿路推进 */
|
|
private _spawnAllPortWinds(plans: WindSpawnPlan[]): void {
|
|
if (!this._effectPlaying) return;
|
|
const session = this._session;
|
|
const map = this._map;
|
|
if (!session || !map) {
|
|
this._abortEffect();
|
|
return;
|
|
}
|
|
|
|
const speed = Math.max(1, session.grid.cellSize) * RETREAT_CELLS_PER_SEC;
|
|
this._retreatSpeedPx = speed;
|
|
this._retreatIds = [];
|
|
this._requeueAfterRetreat.clear();
|
|
this._retreatPathfinderBuilt = false;
|
|
this._portWinds = [];
|
|
this._awaitingWindSweep = false;
|
|
|
|
let windAudioPlayed = false;
|
|
for (const plan of plans) {
|
|
const windNode = gg.res.getNode(GPath.ObstaclePrefab(''), WIND_PREFAB, GBundle.BattleMap);
|
|
if (!windNode?.isValid) {
|
|
console.error('[BajiaoFan] 预制体缺失', WIND_PREFAB);
|
|
this._abortEffect();
|
|
return;
|
|
}
|
|
|
|
windNode.parent = map.node;
|
|
windNode.worldPosition.set(plan.fxStartWorld.x, plan.fxStartWorld.y, plan.fxStartWorld.z);
|
|
windNode.active = true;
|
|
const sorting = windNode.getOrAddComponent(Sorting2D);
|
|
sorting.sortingLayer = 3;
|
|
sorting.sortingOrder = 4;
|
|
setWindNodeOpacity(windNode, 255);
|
|
|
|
const skel = windNode.getComponent(sp.Skeleton) ?? windNode.getComponentInChildren(sp.Skeleton);
|
|
if (!skel?.findAnimation(ANIM_WIND)) {
|
|
console.error('[BajiaoFan] 缺少动画', ANIM_WIND);
|
|
gg.res.putNode(windNode);
|
|
this._abortEffect();
|
|
return;
|
|
}
|
|
skel.setAnimationCacheMode(sp.SpineAnimationCacheMode.REALTIME);
|
|
if (skel?.isValid) BattleSpinePlayback.clearPendingComplete(skel);
|
|
skel.timeScale = 1;
|
|
skel.setAnimation(0, ANIM_WIND, true);
|
|
if (!windAudioPlayed) {
|
|
BattleAudio.playWindBlow();
|
|
windAudioPlayed = true;
|
|
}
|
|
|
|
const windEntityId = session.movement.createForcedPathFollower(plan.path, speed);
|
|
this._portWinds.push({
|
|
port: plan.port,
|
|
windEntityId,
|
|
windNode,
|
|
path: plan.path,
|
|
sweeping: true,
|
|
});
|
|
}
|
|
|
|
this._awaitingWindSweep = this._portWinds.length > 0;
|
|
}
|
|
|
|
private _recycleFanNode(): void {
|
|
const fan = this._fanNode;
|
|
this._fanNode = null;
|
|
if (!fan?.isValid) return;
|
|
const skel = fan.getComponent(sp.Skeleton) ?? fan.getComponentInChildren(sp.Skeleton);
|
|
if (skel?.isValid) BattleSpinePlayback.clearPendingComplete(skel);
|
|
gg.res.putNode(fan);
|
|
}
|
|
|
|
private _recyclePortWind(wind: PortWindState): void {
|
|
const session = this._session;
|
|
if (session && wind.windEntityId !== INVALID_ENTITY_ID && session.world.isAlive(wind.windEntityId)) {
|
|
session.world.destroy(wind.windEntityId);
|
|
}
|
|
wind.windEntityId = INVALID_ENTITY_ID;
|
|
wind.sweeping = false;
|
|
const node = wind.windNode;
|
|
if (!node?.isValid) return;
|
|
const skel = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton);
|
|
if (skel?.isValid) BattleSpinePlayback.clearPendingComplete(skel);
|
|
gg.res.putNode(node);
|
|
}
|
|
|
|
private _clearAllPortWinds(): void {
|
|
for (const wind of this._portWinds) {
|
|
this._recyclePortWind(wind);
|
|
}
|
|
this._portWinds.length = 0;
|
|
}
|
|
|
|
/**
|
|
* 各口旋风推进:该口进路上的怪被对应风碰到后才开始后退;风到终点后处理该口剩余怪。
|
|
*/
|
|
private _tickWindSweep(): void {
|
|
if (!this._awaitingWindSweep) return;
|
|
const session = this._session;
|
|
if (!session || this._portWinds.length === 0) return;
|
|
|
|
const catchPx = WIND_CATCH_CELLS * Math.max(1, session.grid.cellSize);
|
|
const catchSq = catchPx * catchPx;
|
|
let anySweeping = false;
|
|
|
|
for (const wind of this._portWinds) {
|
|
if (!wind.sweeping) continue;
|
|
if (wind.windEntityId === INVALID_ENTITY_ID || !session.world.isAlive(wind.windEntityId)) {
|
|
wind.sweeping = false;
|
|
continue;
|
|
}
|
|
|
|
const windMove = session.world.getComponent(wind.windEntityId, MovementComp);
|
|
if (!windMove) {
|
|
wind.sweeping = false;
|
|
continue;
|
|
}
|
|
|
|
const wtf = session.world.getComponent(wind.windEntityId, TransformComp);
|
|
if (!wtf) {
|
|
wind.sweeping = false;
|
|
continue;
|
|
}
|
|
|
|
const world = session.world;
|
|
world.forEachAlive((id) => {
|
|
if (isMechanismParatroopUnit(session, id)) return;
|
|
if (this._isEntityBusyInBajiao(id)) return;
|
|
if (!canTriggerBajiaoBlow(world, session, id)) return;
|
|
if (getEntitySpawnPort(session, id) !== wind.port) return;
|
|
if (!isOnWindRoute(session, id, wind.path)) return;
|
|
const move = world.getComponent(id, MovementComp);
|
|
const mtf = world.getComponent(id, TransformComp);
|
|
if (!move || !mtf) return;
|
|
if (!isWindCatchEntity(wind.path, wtf, mtf, windMove, move, catchSq)) return;
|
|
this._retreatSpawnMonster(id);
|
|
});
|
|
|
|
this._tickParatroopWindSweep(wind, wind.path, windMove, catchSq);
|
|
|
|
if (session.movement.isOnForcedPath(wind.windEntityId)) {
|
|
anySweeping = true;
|
|
continue;
|
|
}
|
|
|
|
wind.sweeping = false;
|
|
}
|
|
|
|
this._awaitingWindSweep = anySweeping;
|
|
}
|
|
|
|
/** 飞禽:被风碰到后沿进路退回落地点,到位后恢复寻路 */
|
|
private _tickParatroopWindSweep(
|
|
wind: PortWindState,
|
|
windPath: readonly Cell[],
|
|
windMove: MovementComp,
|
|
catchSq: number,
|
|
): void {
|
|
const session = this._session;
|
|
if (!session) return;
|
|
const world = session.world;
|
|
const wtf = world.getComponent(wind.windEntityId, TransformComp);
|
|
if (!wtf) return;
|
|
world.forEachAlive((id) => {
|
|
if (!isMechanismParatroopUnit(session, id)) return;
|
|
if (!canTriggerBajiaoParatroopBlow(world, session, id)) return;
|
|
if (this._isEntityBusyInBajiao(id)) return;
|
|
const move = world.getComponent(id, MovementComp);
|
|
const mtf = world.getComponent(id, TransformComp);
|
|
if (!move || !mtf) return;
|
|
if (!isWindCatchEntity(windPath, wtf, mtf, windMove, move, catchSq)) return;
|
|
this._retreatParatroopToLanding(id);
|
|
});
|
|
}
|
|
|
|
private _isEntityBusyInBajiao(id: EntityId): boolean {
|
|
if (this._requeueAfterRetreat.has(id)) return true;
|
|
for (const rid of this._retreatIds) {
|
|
if (rid === id) return true;
|
|
}
|
|
const session = this._session;
|
|
if (!session) return false;
|
|
const move = session.world.getComponent(id, MovementComp);
|
|
return !!move && (move.forcePathSpeed > 0 || move.bajiaoBlowRetreating);
|
|
}
|
|
|
|
/** 同格多条旋风重叠时只显示 port 最小的一条,分道后各自恢复可见 */
|
|
private _updateWindOverlapVisibility(): void {
|
|
const session = this._session;
|
|
if (!session || this._portWinds.length === 0) return;
|
|
|
|
const world = session.world;
|
|
const byCell = new Map<string, PortWindState[]>();
|
|
for (const wind of this._portWinds) {
|
|
if (!wind.windNode?.isValid) continue;
|
|
setWindNodeOpacity(wind.windNode, 255);
|
|
if (wind.windEntityId === INVALID_ENTITY_ID || !world.isAlive(wind.windEntityId)) continue;
|
|
if (!session.movement.isOnForcedPath(wind.windEntityId)) continue;
|
|
const tf = world.getComponent(wind.windEntityId, TransformComp);
|
|
if (!tf) continue;
|
|
const key = `${tf.row},${tf.col}`;
|
|
const list = byCell.get(key);
|
|
if (list) {
|
|
list.push(wind);
|
|
} else {
|
|
byCell.set(key, [wind]);
|
|
}
|
|
}
|
|
|
|
for (const group of byCell.values()) {
|
|
if (group.length <= 1) continue;
|
|
group.sort((a, b) => a.port - b.port);
|
|
for (let i = 1; i < group.length; i++) {
|
|
setWindNodeOpacity(group[i].windNode, 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 强制路径走完时销毁跟随实体并回收旋风节点 */
|
|
private _recycleFinishedWinds(): void {
|
|
const session = this._session;
|
|
if (!session) return;
|
|
for (let i = this._portWinds.length - 1; i >= 0; i--) {
|
|
const wind = this._portWinds[i];
|
|
if (wind.windEntityId === INVALID_ENTITY_ID) {
|
|
this._portWinds.splice(i, 1);
|
|
continue;
|
|
}
|
|
if (session.movement.isOnForcedPath(wind.windEntityId)) continue;
|
|
this._recyclePortWind(wind);
|
|
this._portWinds.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
/** 出怪口怪:风碰到后沿进路后退 Const76 格;仅退到出怪口才进放行队列 */
|
|
private _retreatSpawnMonster(id: EntityId): void {
|
|
const session = this._session;
|
|
if (!session?.world.isAlive(id)) return;
|
|
if (isMechanismParatroopUnit(session, id)) return;
|
|
if (!this._retreatPathfinderBuilt) {
|
|
session.pathfinder.rebuild(session.topology);
|
|
this._retreatPathfinderBuilt = true;
|
|
}
|
|
|
|
const world = session.world;
|
|
if (isMechanismUnit(session, id)) return;
|
|
|
|
const move = world.getComponent(id, MovementComp);
|
|
const tf = world.getComponent(id, TransformComp);
|
|
if (!move || !tf || !canTriggerBajiaoBlow(world, session, id)) return;
|
|
|
|
if (move.spawnProtected) {
|
|
const home = resolveEntitySpawnCell(session, id);
|
|
if (home) {
|
|
const plan = resolveSpawnMonsterBajiaoRetreat(
|
|
session,
|
|
{ row: tf.row, col: tf.col },
|
|
home,
|
|
resolveRetreatMaxCells(),
|
|
);
|
|
if (plan) {
|
|
move.spawnProtected = false;
|
|
this._applyForcedRetreat(id, plan.path, plan.holdAtHome);
|
|
return;
|
|
}
|
|
}
|
|
this._holdAtBorn(this._sortIdsForBajiaoHold(session, [id]));
|
|
return;
|
|
}
|
|
|
|
const home = resolveEntitySpawnCell(session, id);
|
|
if (!home) {
|
|
this._holdAtBorn(this._sortIdsForBajiaoHold(session, [id]));
|
|
return;
|
|
}
|
|
|
|
const plan = resolveSpawnMonsterBajiaoRetreat(
|
|
session,
|
|
{ row: tf.row, col: tf.col },
|
|
home,
|
|
resolveRetreatMaxCells(),
|
|
);
|
|
if (!plan) {
|
|
this._holdAtBorn(this._sortIdsForBajiaoHold(session, [id]));
|
|
return;
|
|
}
|
|
this._applyForcedRetreat(id, plan.path, plan.holdAtHome);
|
|
}
|
|
|
|
/** 飞禽:被风碰到后沿落地点进路退回落地点 */
|
|
private _retreatParatroopToLanding(id: EntityId): void {
|
|
const session = this._session;
|
|
if (!session?.world.isAlive(id)) return;
|
|
const tf = session.world.getComponent(id, TransformComp);
|
|
const landing = resolveParatroopLandingCell(session, id);
|
|
if (!tf || !landing) return;
|
|
if (!this._retreatPathfinderBuilt) {
|
|
session.pathfinder.rebuild(session.topology);
|
|
this._retreatPathfinderBuilt = true;
|
|
}
|
|
|
|
const distToLanding = getGridDistAlongAdvanceRoute(session, landing, tf.row, tf.col);
|
|
if (!Number.isFinite(distToLanding) || distToLanding <= 0) {
|
|
session.movement.assignPath(id);
|
|
return;
|
|
}
|
|
|
|
const path = session.pathfinder.buildRetreatPath(
|
|
{ row: tf.row, col: tf.col },
|
|
distToLanding,
|
|
landing,
|
|
);
|
|
this._applyForcedRetreat(id, path, false);
|
|
}
|
|
|
|
private _applyForcedRetreat(id: EntityId, path: Cell[], holdAtHome: boolean): void {
|
|
const session = this._session;
|
|
if (!session?.world.isAlive(id)) return;
|
|
const world = session.world;
|
|
const move = world.getComponent(id, MovementComp);
|
|
if (!move) return;
|
|
|
|
move.presentLocked = false;
|
|
move.forcePathSpeed = 0;
|
|
move.forcePathHoldAtEnd = false;
|
|
move.path = [];
|
|
move.pathIndex = 0;
|
|
move.reachedGoal = false;
|
|
move.hasAttackGoal = false;
|
|
move.bajiaoBlowRetreating = true;
|
|
|
|
const moved = path.length > 1
|
|
? session.movement.setForcedPath(id, path, this._retreatSpeedPx)
|
|
: 0;
|
|
if (moved <= 0) {
|
|
move.bajiaoBlowRetreating = false;
|
|
if (holdAtHome) {
|
|
this._holdAtBorn(this._sortIdsForBajiaoHold(session, [id]));
|
|
} else {
|
|
session.movement.assignPath(id);
|
|
}
|
|
return;
|
|
}
|
|
if (holdAtHome) {
|
|
move.forcePathHoldAtEnd = true;
|
|
membershipAdd(this._requeueAfterRetreat, id);
|
|
}
|
|
this._retreatIds.push(id);
|
|
}
|
|
|
|
/** 同出怪口:距出生口由远到近排序(先吹回的先出) */
|
|
private _sortIdsForBajiaoHold(session: BattleSession, ids: EntityId[]): EntityId[] {
|
|
return ids.slice().sort((a, b) => {
|
|
const ma = session.world.getComponent(a, UnitMetaComp);
|
|
const mb = session.world.getComponent(b, UnitMetaComp);
|
|
const pa = ma?.spawnPoint ?? 0;
|
|
const pb = mb?.spawnPoint ?? 0;
|
|
if (pa !== pb) return pa - pb;
|
|
return bajiaoDistFromHome(session, b) - bajiaoDistFromHome(session, a);
|
|
});
|
|
}
|
|
|
|
/** 退到出怪口后:立刻隐藏并收回出怪队列(踏进出生格即收,不等强制路径尾帧空转) */
|
|
private _tickRequeueAfterRetreat(): void {
|
|
if (this._requeueAfterRetreat.size === 0) return;
|
|
const session = this._session;
|
|
if (!session) {
|
|
this._requeueAfterRetreat.clear();
|
|
return;
|
|
}
|
|
const ready: EntityId[] = [];
|
|
for (const id of membershipToArray(this._requeueAfterRetreat)) {
|
|
if (!session.world.isAlive(id)) {
|
|
this._requeueAfterRetreat.delete(id);
|
|
continue;
|
|
}
|
|
const tf = session.world.getComponent(id, TransformComp);
|
|
const home = resolveEntityHomeCell(session, id);
|
|
const onHome = !!(tf && home && tf.row === home.row && tf.col === home.col);
|
|
// 已踏进回收点(出怪口/飞禽落地格):立刻入队隐藏
|
|
if (!onHome && session.movement.isOnForcedPath(id)) continue;
|
|
ready.push(id);
|
|
this._requeueAfterRetreat.delete(id);
|
|
}
|
|
if (ready.length > 0) {
|
|
this._holdAtBorn(this._sortIdsForBajiaoHold(session, ready));
|
|
}
|
|
}
|
|
|
|
private _holdAtBorn(ids: readonly EntityId[]): void {
|
|
const battle = gg.game.CurentBattle as {
|
|
holdMonstersAtBornForBajiaoFan?: (entityIds: readonly EntityId[]) => void;
|
|
} | null;
|
|
battle?.holdMonstersAtBornForBajiaoFan?.(ids);
|
|
}
|
|
|
|
private _tickEffect(dt: number): void {
|
|
if (!this._effectPlaying) return;
|
|
const session = this._session;
|
|
const map = this._map;
|
|
if (!session || !map) {
|
|
this._abortEffect();
|
|
return;
|
|
}
|
|
|
|
if (this._windSpawnTimer >= 0) {
|
|
this._windSpawnTimer -= dt;
|
|
if (this._windSpawnTimer <= 0) {
|
|
this._windSpawnTimer = -1;
|
|
const plans = this._pendingWindPlans;
|
|
this._pendingWindPlans = null;
|
|
if (plans && plans.length > 0) {
|
|
this._spawnAllPortWinds(plans);
|
|
} else {
|
|
this._abortEffect();
|
|
return;
|
|
}
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (this._portWinds.length > 0) {
|
|
for (const wind of this._portWinds) {
|
|
if (!wind.windNode?.isValid) {
|
|
this._abortEffect();
|
|
return;
|
|
}
|
|
if (wind.windEntityId !== INVALID_ENTITY_ID && session.world.isAlive(wind.windEntityId)) {
|
|
syncMovementNode(session, map, wind.windEntityId, wind.windNode);
|
|
}
|
|
}
|
|
this._updateWindOverlapVisibility();
|
|
this._tickWindSweep();
|
|
this._recycleFinishedWinds();
|
|
}
|
|
|
|
if (!this._awaitingWindSweep && this._isRetreatWaveDone()) {
|
|
this._finishEffect();
|
|
}
|
|
}
|
|
|
|
/** 所有旋风已收,且本波后移怪均已离开强制路径、入队待处理为空 */
|
|
private _isRetreatWaveDone(): boolean {
|
|
const session = this._session;
|
|
if (!session) return true;
|
|
if (this._portWinds.length > 0) return false;
|
|
if (this._awaitingWindSweep) return false;
|
|
if (this._requeueAfterRetreat.size > 0) return false;
|
|
for (const id of this._retreatIds) {
|
|
if (!session.world.isAlive(id)) continue;
|
|
if (!session.movement.isOnForcedPath(id)) continue;
|
|
if (!canAdvanceForcedRetreat(session, id)) {
|
|
abortStuckForcedRetreat(session, id);
|
|
continue;
|
|
}
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private _finishEffect(): void {
|
|
this._awaitingWindSweep = false;
|
|
this._windSpawnTimer = -1;
|
|
this._pendingWindPlans = null;
|
|
const session = this._session;
|
|
if (session && this._requeueAfterRetreat.size > 0) {
|
|
const pending = this._sortIdsForBajiaoHold(session, membershipToArray(this._requeueAfterRetreat));
|
|
this._requeueAfterRetreat.clear();
|
|
this._holdAtBorn(pending);
|
|
}
|
|
this._clearAllPortWinds();
|
|
this._retreatIds.length = 0;
|
|
this._recycleFanNode();
|
|
this._effectPlaying = false;
|
|
this._effectCampAreaId = 0;
|
|
}
|
|
|
|
private _abortEffect(): void {
|
|
this._awaitingWindSweep = false;
|
|
this._windSpawnTimer = -1;
|
|
this._pendingWindPlans = null;
|
|
this._effectCampAreaId = 0;
|
|
const session = this._session;
|
|
if (session && this._requeueAfterRetreat.size > 0) {
|
|
const pending = this._sortIdsForBajiaoHold(session, membershipToArray(this._requeueAfterRetreat));
|
|
this._requeueAfterRetreat.clear();
|
|
this._holdAtBorn(pending);
|
|
}
|
|
if (session) {
|
|
for (const id of this._retreatIds) {
|
|
if (!session.world.isAlive(id)) continue;
|
|
finishBajiaoForcedRetreatStuck(session, id);
|
|
}
|
|
}
|
|
this._clearAllPortWinds();
|
|
this._retreatIds.length = 0;
|
|
this._recycleFanNode();
|
|
this._effectPlaying = false;
|
|
}
|
|
|
|
private _setCampVisible(slot: BajiaoCampSlot, visible: boolean): void {
|
|
const node = slot.node;
|
|
if (!node?.isValid) return;
|
|
if (visible === slot.visible) return;
|
|
slot.visible = visible;
|
|
if (!visible) {
|
|
this._stopSlotBreath(slot);
|
|
node.active = false;
|
|
return;
|
|
}
|
|
this._refreshNumLabels();
|
|
node.active = true;
|
|
this._playSlotAppearBreath(slot);
|
|
}
|
|
|
|
private _setAllCampVisible(visible: boolean): void {
|
|
for (const slot of this._campSlots) {
|
|
this._setCampVisible(slot, visible);
|
|
}
|
|
}
|
|
|
|
private _playSlotAppearBreath(slot: BajiaoCampSlot): void {
|
|
const node = slot.node;
|
|
if (!node?.isValid) return;
|
|
this._stopSlotBreath(slot);
|
|
const s = slot.baseScale;
|
|
node.setScale(s, s, 1);
|
|
tween(node)
|
|
.repeatForever(
|
|
tween(node)
|
|
.to(BREATH_HALF_SEC, { scale: v3(s * BREATH_SCALE, s * BREATH_SCALE, 1) }, { easing: 'sineInOut' })
|
|
.to(BREATH_HALF_SEC, { scale: v3(s, s, 1) }, { easing: 'sineInOut' }),
|
|
)
|
|
.start();
|
|
}
|
|
|
|
private _stopSlotBreath(slot: BajiaoCampSlot): void {
|
|
const node = slot.node;
|
|
if (!node?.isValid) return;
|
|
Tween.stopAllByTarget(node);
|
|
node.setScale(slot.baseScale, slot.baseScale, 1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 攻击站立格沿指定出怪口→攻击点进路回退 steps 格。
|
|
* 未传 spawnGrid 时默认 spawnPoint_1(气泡定位)。
|
|
*/
|
|
export function resolveAttackPointBehindGrid(
|
|
map: MapManager,
|
|
session: BattleSession,
|
|
stand: MapGrid,
|
|
steps: number,
|
|
spawnGrid?: MapGrid | null,
|
|
campAreaId?: number,
|
|
): MapGrid | null {
|
|
const spawn = spawnGrid ?? resolveSpawnGridByPort(map, session, 1);
|
|
if (!spawn) return null;
|
|
session.pathfinder.rebuild(session.topology);
|
|
const from: Cell = { row: spawn.Row, col: spawn.Col };
|
|
const to: Cell = { row: stand.Row, col: stand.Col };
|
|
let path = session.pathfinder.buildPathToTarget(from, to, campAreaId);
|
|
if (path.length < steps + 1) {
|
|
path = session.pathfinder.findPath(from, to, true, campAreaId);
|
|
}
|
|
if (path.length < steps + 1) {
|
|
return null;
|
|
}
|
|
const cell = path[path.length - 1 - steps];
|
|
return map.getMapGrid(cell.row, cell.col);
|
|
}
|
|
|
|
/** 气泡落点:仅尝试本阵营出怪口进路回退 */
|
|
function resolveIconBehindGridForCamp(
|
|
map: MapManager,
|
|
session: BattleSession,
|
|
stand: MapGrid,
|
|
steps: number,
|
|
campAreaId: number,
|
|
): MapGrid | null {
|
|
for (const port of collectSpawnPortsForCamp(session, campAreaId)) {
|
|
const spawnGrid = resolveSpawnGridByPort(map, session, port);
|
|
if (!spawnGrid) continue;
|
|
const behind = resolveAttackPointBehindGrid(
|
|
map, session, stand, steps, spawnGrid, campAreaId,
|
|
);
|
|
if (behind && !(behind.Row === stand.Row && behind.Col === stand.Col)) {
|
|
return behind;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isChapter5BajiaoIconSpecial(): boolean {
|
|
const battle = gg.game?.CurentBattle as { ChapterId?: number; chapterId?: number; MapId?: number } | null;
|
|
if (!battle) return false;
|
|
return battle.ChapterId === CHAPTER5_BAJIAO_ICON_ID
|
|
|| battle.chapterId === CHAPTER5_BAJIAO_ICON_ID
|
|
|| battle.MapId === CHAPTER5_BAJIAO_ICON_ID;
|
|
}
|
|
|
|
/** 第 5 关:唐僧站立格向左(Col-1)一格;不可走则同排再偏一格 */
|
|
function resolveChapter5IconGrid(map: MapManager, stand: MapGrid): MapGrid | null {
|
|
const tryCols = [stand.Col - 1, stand.Col - 2];
|
|
for (const col of tryCols) {
|
|
const grid = map.getMapGrid(stand.Row, col);
|
|
if (grid?.isCanWalk) return grid;
|
|
}
|
|
for (const dr of [-1, 1]) {
|
|
const grid = map.getMapGrid(stand.Row + dr, stand.Col - 1);
|
|
if (grid?.isCanWalk) return grid;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function collectSpawnPorts(session: BattleSession): number[] {
|
|
return collectSpawnPortsForCamp(session);
|
|
}
|
|
|
|
/** 指定阵营区域的出怪口列表 */
|
|
function collectSpawnPortsForCamp(session: BattleSession, campAreaId?: number): number[] {
|
|
const ports = createMembershipSet<number>();
|
|
for (const port of session.topology.spawnPorts.keys()) {
|
|
if (port <= 0) continue;
|
|
if (campAreaId !== undefined) {
|
|
const area = session.topology.getSpawnPortCampArea(port);
|
|
if (area !== campAreaId) continue;
|
|
}
|
|
membershipAdd(ports, port);
|
|
}
|
|
if (ports.size === 0) {
|
|
if (campAreaId === undefined) {
|
|
membershipAdd(ports, 1);
|
|
membershipAdd(ports, 2);
|
|
membershipAdd(ports, 3);
|
|
}
|
|
}
|
|
return membershipToArray(ports).sort((a, b) => a - b);
|
|
}
|
|
|
|
/** 旋风路径:特效起点沿出怪口进路一路退回至出怪口(全程,不受 Const76 限制) */
|
|
function buildWindPathToSpawn(
|
|
session: BattleSession,
|
|
fxStart: Cell,
|
|
spawn: Cell,
|
|
): Cell[] {
|
|
if (fxStart.row === spawn.row && fxStart.col === spawn.col) {
|
|
return [{ ...fxStart }];
|
|
}
|
|
session.pathfinder.rebuild(session.topology);
|
|
const path = session.pathfinder.buildRetreatPath(fxStart, Number.MAX_SAFE_INTEGER, spawn);
|
|
return path.length > 0 ? path : session.pathfinder.findPath(fxStart, spawn, true);
|
|
}
|
|
|
|
function resolveSpawnGridByPort(
|
|
map: MapManager,
|
|
session: BattleSession,
|
|
port: number,
|
|
): MapGrid | null {
|
|
const cell = session.topology.getSpawnCell(port);
|
|
if (cell) {
|
|
const grid = map.getMapGrid(cell.row, cell.col);
|
|
if (grid?.isCanWalk) return grid;
|
|
}
|
|
const spawnNode = map.spawnPointParent?.getChildByName(`spawnPoint_${port}`);
|
|
if (!spawnNode) return null;
|
|
const spawnGrid = map.getMapGridByWorldPos(spawnNode.worldPosition);
|
|
if (!spawnGrid?.isCanWalk) return null;
|
|
return spawnGrid;
|
|
}
|
|
|
|
/** 单怪被风碰到后沿进路后退的格数(旋风行程不受此限制) */
|
|
function resolveRetreatMaxCells(): number {
|
|
return BAJIAO_RETREAT_CELLS;
|
|
}
|
|
|
|
/**
|
|
* 出怪口怪被风碰到时:按当前格单独算后退停格(与旋风是否已到出怪口无关)。
|
|
* - 后退格数 = min(Const76, 距出怪口格数)
|
|
* - 未到出怪口:退满配置格数后恢复寻路
|
|
* - 距出怪口 ≤ Const76:退到出怪口并进放行队列
|
|
*/
|
|
function resolveSpawnMonsterBajiaoRetreat(
|
|
session: BattleSession,
|
|
from: Cell,
|
|
home: Cell,
|
|
configCells: number,
|
|
): { path: Cell[]; holdAtHome: boolean } | null {
|
|
const distToHome = getGridDistAlongAdvanceRoute(session, home, from.row, from.col);
|
|
if (!Number.isFinite(distToHome) || distToHome <= 0) return null;
|
|
|
|
const retreatCells = Math.min(configCells, distToHome);
|
|
const path = session.pathfinder.buildRetreatPath(from, retreatCells, home);
|
|
if (path.length < 2) return null;
|
|
|
|
return {
|
|
path,
|
|
holdAtHome: distToHome <= configCells,
|
|
};
|
|
}
|
|
|
|
/** 当前格在自身出怪口进路上距出生口的格数;不在进路上用最近进路格估算 */
|
|
function getGridDistAlongAdvanceRoute(
|
|
session: BattleSession,
|
|
spawn: Cell,
|
|
row: number,
|
|
col: number,
|
|
): number {
|
|
const path = session.pathfinder.buildRetreatPath(
|
|
{ row, col },
|
|
Number.MAX_SAFE_INTEGER,
|
|
spawn,
|
|
);
|
|
if (path.length === 0) return Number.POSITIVE_INFINITY;
|
|
return Math.max(0, path.length - 1);
|
|
}
|
|
|
|
function bajiaoDistFromHome(session: BattleSession, entityId: EntityId): number {
|
|
const tf = session.world.getComponent(entityId, TransformComp);
|
|
const home = resolveEntityHomeCell(session, entityId);
|
|
if (!tf || !home) return 0;
|
|
const dist = getGridDistAlongAdvanceRoute(session, home, tf.row, tf.col);
|
|
return Number.isFinite(dist) ? dist : 0;
|
|
}
|
|
|
|
function resolveEntitySpawnCell(session: BattleSession, entityId: EntityId): Cell | null {
|
|
const meta = session.world.getComponent(entityId, UnitMetaComp);
|
|
const port = meta?.spawnPoint && meta.spawnPoint > 0 ? meta.spawnPoint : 1;
|
|
return session.topology.getSpawnCell(port);
|
|
}
|
|
|
|
/** 后退/入队回收点:飞禽用落地格,其余用出怪口 */
|
|
function resolveEntityHomeCell(session: BattleSession, entityId: EntityId): Cell | null {
|
|
if (isMechanismParatroopUnit(session, entityId)) {
|
|
const meta = session.world.getComponent(entityId, UnitMetaComp);
|
|
if (meta && meta.homeRow >= 0 && meta.homeCol >= 0) {
|
|
return { row: meta.homeRow, col: meta.homeCol };
|
|
}
|
|
}
|
|
return resolveEntitySpawnCell(session, entityId);
|
|
}
|
|
|
|
function hasEnemyWithinPathCellsForCamp(
|
|
session: BattleSession,
|
|
campAreaId: number,
|
|
thresholdCells: number,
|
|
): boolean {
|
|
const thresholdPx = Math.max(1, thresholdCells) * Math.max(1, session.grid.cellSize);
|
|
let found = false;
|
|
const world = session.world;
|
|
world.forEachAlive((id) => {
|
|
if (found) return;
|
|
if (!isTangAttackableEnemy(world, session, id)) return;
|
|
if (getEntityCampArea(session, id) !== campAreaId) return;
|
|
const move = world.getComponent(id, MovementComp);
|
|
if (!move || move.spawnProtected || isYanSoulClaimed(move)) return;
|
|
if (move.reachedGoal) {
|
|
found = true;
|
|
return;
|
|
}
|
|
const remaining = session.movement.getRemainingPathLength(id);
|
|
if (Number.isFinite(remaining) && remaining <= thresholdPx) {
|
|
found = true;
|
|
}
|
|
});
|
|
return found;
|
|
}
|
|
|
|
/** 怪物所属阵营区域:出怪口 campAreaId */
|
|
function getEntityCampArea(session: BattleSession, entityId: EntityId): number {
|
|
const meta = session.world.getComponent(entityId, UnitMetaComp);
|
|
const port = meta?.spawnPoint && meta.spawnPoint > 0 ? meta.spawnPoint : 1;
|
|
return session.topology.getSpawnPortCampArea(port) ?? 1;
|
|
}
|
|
|
|
/**
|
|
* 会攻击唐僧的敌方单位:普通出怪口怪 + 空投飞禽。
|
|
* 不含白骨精等 mechanism_unit(与 CocosBattleMovementBridge.isWallAttackForbidden 一致)。
|
|
* 移动中、已在攻墙(reachedGoal)均属此类。
|
|
*/
|
|
function isTangAttackableEnemy(
|
|
world: BattleSession['world'],
|
|
session: BattleSession,
|
|
id: EntityId,
|
|
): boolean {
|
|
if (!isEnemyEntity(world, id)) return false;
|
|
if (isMechanismUnit(session, id)) return false;
|
|
return true;
|
|
}
|
|
|
|
/** 出怪口怪:当前可被旋风触发吹退(已在吹退中、出生跳跃等除外) */
|
|
function canTriggerBajiaoBlow(
|
|
world: BattleSession['world'],
|
|
session: BattleSession,
|
|
id: EntityId,
|
|
): boolean {
|
|
if (!isTangAttackableEnemy(world, session, id)) return false;
|
|
if (isMechanismParatroopUnit(session, id)) return false;
|
|
const move = world.getComponent(id, MovementComp);
|
|
if (!move || move.imprisoned || isYanSoulClaimed(move)) return false;
|
|
if (move.presentLocked) return false;
|
|
if (move.spawnProtected) {
|
|
const tf = world.getComponent(id, TransformComp);
|
|
const home = resolveEntityHomeCell(session, id);
|
|
if (!tf || !home) return false;
|
|
if (tf.row === home.row && tf.col === home.col) return false;
|
|
}
|
|
if (move.forcePathSpeed > 0 || move.bajiaoBlowRetreating) return false;
|
|
return true;
|
|
}
|
|
|
|
/** 飞禽:当前可被旋风触发吹退 */
|
|
function canTriggerBajiaoParatroopBlow(
|
|
world: BattleSession['world'],
|
|
session: BattleSession,
|
|
id: EntityId,
|
|
): boolean {
|
|
if (!isMechanismParatroopUnit(session, id)) return false;
|
|
if (!isEnemyEntity(world, id)) return false;
|
|
const move = world.getComponent(id, MovementComp);
|
|
if (!move || move.imprisoned || isYanSoulClaimed(move)) return false;
|
|
if (move.spawnProtected || move.presentLocked) return false;
|
|
if (move.forcePathSpeed > 0 || move.bajiaoBlowRetreating) return false;
|
|
return true;
|
|
}
|
|
|
|
function resolveParatroopLandingCell(session: BattleSession, entityId: EntityId): Cell | null {
|
|
const meta = session.world.getComponent(entityId, UnitMetaComp);
|
|
if (!meta || meta.homeRow < 0 || meta.homeCol < 0) return null;
|
|
return { row: meta.homeRow, col: meta.homeCol };
|
|
}
|
|
|
|
function getEntitySpawnPort(session: BattleSession, entityId: EntityId): number {
|
|
const meta = session.world.getComponent(entityId, UnitMetaComp);
|
|
return meta?.spawnPoint && meta.spawnPoint > 0 ? meta.spawnPoint : 1;
|
|
}
|
|
|
|
function setWindNodeOpacity(node: Node, opacity: number): void {
|
|
const ui = node.getComponent(UIOpacity) ?? node.addComponent(UIOpacity);
|
|
ui.opacity = opacity;
|
|
}
|
|
|
|
/** 旋风路径上格索引;不在路径上返回 -1 */
|
|
function resolvePathCellIndex(path: readonly Cell[], row: number, col: number): number {
|
|
for (let i = 0; i < path.length; i++) {
|
|
if (path[i].row === row && path[i].col === col) return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/**
|
|
* 风是否已碰到怪:优先按路径格序(低帧率不穿透),辅以世界距离兜底。
|
|
* 旋风沿 path[0]→path[end] 推进,windIdx ≥ monsterIdx 即视为风已到达该怪。
|
|
*/
|
|
function isWindCatchEntity(
|
|
windPath: readonly Cell[],
|
|
windTf: TransformComp,
|
|
monsterTf: TransformComp,
|
|
windMove: MovementComp,
|
|
monsterMove: MovementComp,
|
|
catchSq: number,
|
|
): boolean {
|
|
if (windPath.length < 2) return false;
|
|
const windIdx = resolvePathCellIndex(windPath, windTf.row, windTf.col);
|
|
const monsterIdx = resolvePathCellIndex(windPath, monsterTf.row, monsterTf.col);
|
|
if (windIdx >= 0 && monsterIdx >= 0 && windIdx >= monsterIdx) {
|
|
return true;
|
|
}
|
|
const dx = monsterMove.worldX - windMove.worldX;
|
|
const dy = monsterMove.worldY - windMove.worldY;
|
|
return dx * dx + dy * dy <= catchSq;
|
|
}
|
|
|
|
/** 怪在旋风路径上:当前格或退回出怪口路径与风路径有交集 */
|
|
function isOnWindRoute(
|
|
session: BattleSession,
|
|
entityId: EntityId,
|
|
windPath: readonly Cell[],
|
|
): boolean {
|
|
if (windPath.length === 0) return false;
|
|
const tf = session.world.getComponent(entityId, TransformComp);
|
|
if (!tf) return false;
|
|
|
|
const onPath = membershipFromArray(windPath.map((c) => `${c.row},${c.col}`));
|
|
if (onPath.has(`${tf.row},${tf.col}`)) return true;
|
|
|
|
const home = resolveEntityHomeCell(session, entityId);
|
|
if (!home) return false;
|
|
const retreatPath = session.pathfinder.buildRetreatPath(
|
|
{ row: tf.row, col: tf.col },
|
|
Number.MAX_SAFE_INTEGER,
|
|
home,
|
|
);
|
|
for (const cell of retreatPath) {
|
|
if (onPath.has(`${cell.row},${cell.col}`)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** MovementSystem 在 imprisoned / presentLocked 时不推进强制路径 */
|
|
function canAdvanceForcedRetreat(session: BattleSession, id: EntityId): boolean {
|
|
const move = session.world.getComponent(id, MovementComp);
|
|
if (!move || move.forcePathSpeed <= 0) return false;
|
|
return !move.imprisoned && !move.presentLocked;
|
|
}
|
|
|
|
/** 后移等待中实体已无法推进强制路径时收尾 */
|
|
function abortStuckForcedRetreat(session: BattleSession, id: EntityId): void {
|
|
finishBajiaoForcedRetreatStuck(session, id);
|
|
}
|
|
|
|
/** 芭蕉扇强制后退卡住 / 扇表现 abort:回收或继续后退,禁止 assignPath 朝唐僧冲 */
|
|
function finishBajiaoForcedRetreatStuck(session: BattleSession, id: EntityId): void {
|
|
const move = session.world.getComponent(id, MovementComp);
|
|
if (!move) return;
|
|
const wasBajiao = move.bajiaoBlowRetreating || move.forcePathSpeed > 0;
|
|
if (!wasBajiao) return;
|
|
move.forcePathSpeed = 0;
|
|
move.bajiaoBlowRetreating = false;
|
|
move.path = [];
|
|
move.pathIndex = 0;
|
|
move.forcePathHoldAtEnd = false;
|
|
if (move.imprisoned) return;
|
|
if (isMechanismParatroopUnit(session, id)) {
|
|
move.reachedGoal = false;
|
|
move.forcePresentSync = true;
|
|
session.movement.assignPath(id);
|
|
return;
|
|
}
|
|
const battle = gg.game.CurentBattle as {
|
|
holdMonstersAtBornForBajiaoFan?: (entityIds: readonly EntityId[]) => void;
|
|
} | null;
|
|
battle?.holdMonstersAtBornForBajiaoFan?.([id]);
|
|
}
|
|
|
|
function isEnemyEntity(
|
|
world: BattleSession['world'],
|
|
id: EntityId,
|
|
): boolean {
|
|
const tags = world.getComponent(id, TagComp);
|
|
const team = world.getComponent(id, TeamComp);
|
|
return !!tags?.tags.includes(EntityTag.Enemy)
|
|
|| !!(team && BattleTeamId.isEnemyTeam(team.team));
|
|
}
|
|
|