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