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/script/battle/view/BajiaoFanIconPresenter.ts

1343 lines
49 KiB

3 weeks ago
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));
}