import type { EntityId } from 'db://assets/mx/module/ecs'; import { createMembershipSet, membershipAdd, membershipFromArray, membershipDedupeArray, type MembershipSet, type ReadonlyMembershipSet } from '../../../mx/tools/MembershipSet'; import { BattleTeamId, BattlePhase, EntityTag, HealthComp, MovementComp, TagComp, TeamComp, TransformComp, UnitFactory, UnitMetaComp, AttributeComp, AbilitySetComp, type BattleSession, } from 'db://assets/mx/module/battle'; import { mapGridToLogicCell } from '../map/BattleMapSpec'; import { findNearestCurrentlyWalkableCell, isParatroopForbiddenNearTangSeng } from '../map/MapPathQueries'; import type { MapTopology } from 'db://assets/mx/module/battle/battlefield/MapTopology'; import type { MapGrid } from '../map/legacy/MapGrid'; import type { MapManager } from '../map/legacy/MapManager'; import { calcChapterMonsterHp, makeMonsterUnitDefId } from '../config/MonsterHpCalculator'; import { readMonsterConfigFromLevel } from '../config/LevelMonsterConfig'; import type { MechanismContext } from './IBattleMechanism'; import { syncMonsterNode, applyMonsterFootOffset } from '../bridge/CocosBattleMovementBridge'; import { filterGridsByBusiestMonsterArea, isRowInRange, shuffleMapGrids, type MapRowRange, } from './MechanismSpawnArea'; import { composeKey } from '../runtime/ComposeUnitKey'; import { findCompositionForEntity, findEntityIdByGridIndex } from '../runtime/DeployEntityQuery'; import { getTowerLoadoutService } from '../combat/TowerLoadoutService'; import { WeaponComposeConfigProvider } from '../bag/WeaponComposeConfigProvider'; import type { ComposedUnit } from 'db://assets/mx/module/merge'; import type { CharmController } from './CharmController'; import { BattleSpinePlayback } from '../presentation/BattleSpinePlayback'; import { BLOOD_MOON_ATTACK_ANIM, BLOOD_MOON_MECHANISM_PREFAB, BLOOD_MOON_MODIFIER_GROUP, FOX_CHARM_FLY_MAX_SEC, FOX_CHARM_FLY_MIN_SEC, FOX_CHARM_FLY_SPEED, FOX_SPIRIT_ATTACK_ANIM, FOX_SPIRIT_IDLE_ANIM, FOX_SPIRIT_MECHANISM_PREFAB, MECHANISM_DROP_DURATION_SEC, MECHANISM_DROP_OFFSET_Y, MECHANISM_FADE_IN_SEC, MECHANISM_FADE_OUT_SEC, MECHANISM_PARATROOP_TAG, MECHANISM_UNIT_TAG, MECHANISM_WITCH_TAG, SPIDER_SILK_FLY_ANIM, SPIDER_SILK_FLY_MAX_SEC, SPIDER_SILK_FLY_MIN_SEC, SPIDER_SILK_FLY_SPEED, SPIDER_SILK_HIT_ANIM, SPIDER_SILK_LOOP_ANIM, SPIDER_SPIRIT_ATTACK_ANIM, SPIDER_SPIRIT_IDLE_ANIM, SPIDER_SPIRIT_MECHANISM_PREFAB, WITCH_MECHANISM_MONSTER_ID, } from './MechanismConstants'; import type { TableBattleConfigProvider } from '../config/TableBattleConfigProvider'; import { BATTLE_MONSTER_CCCLASS } from '../view/IBattleMonsterView'; import { getMonsterStatusText } from '../view/MonsterStatusText'; import { MonsterState } from '../../ui/BattleGame/MonsterState'; import { GBundle } from '../../game/ConfigRes'; import { BattleAudio } from '../audio/BattleAudio'; import { instantiate, isValid, Node, sp, tween, Tween, UIOpacity, v3, Vec3, easing } from 'cc'; /** 白骨精 / 空投飞禽:击杀不计入 MonsterDeadNum / 胜利进度 */ export function isMechanismKillExcluded(session: MechanismContext['session'], victim: EntityId): boolean { const tags = session.world.getComponent(victim, TagComp); if (!tags) return false; return tags.tags.includes(MECHANISM_UNIT_TAG) || tags.tags.includes(MECHANISM_PARATROOP_TAG); } export function isMechanismUnit(session: MechanismContext['session'], entityId: EntityId): boolean { const tags = session.world.getComponent(entityId, TagComp); return tags?.tags.includes(MECHANISM_UNIT_TAG) ?? false; } export function isMechanismWitchUnit(session: MechanismContext['session'], entityId: EntityId): boolean { const tags = session.world.getComponent(entityId, TagComp); return tags?.tags.includes(MECHANISM_WITCH_TAG) ?? false; } export function isMechanismParatroopUnit(session: MechanismContext['session'], entityId: EntityId): boolean { const tags = session.world.getComponent(entityId, TagComp); return tags?.tags.includes(MECHANISM_PARATROOP_TAG) ?? false; } /** * 白骨精血量(对齐策划:用当前波次怪物基础血量) * - 波次基础血量:当前波各出怪段 maxHp 按数量加权平均 * - 最终:floor(waveBaseHp × (1 + addHpCoefficient)),与旧 BattleCore 一致 */ export function calcWitchMechanismMaxHp(ctx: MechanismContext, hpCoeff: number): number { const waveBaseHp = getCurrentWaveMonsterBaseHp(ctx); const coeff = Math.max(0, Number(hpCoeff) || 0); return Math.max(1, Math.floor(waveBaseHp * (1 + coeff))); } /** 当前波次怪物基础血量(出怪配表已算好的 maxHp) */ export function getCurrentWaveMonsterBaseHp(ctx: MechanismContext): number { const waves = ctx.session.levelDef?.waves; if (!waves?.length) { console.error('[MechanismServices] levelDef.waves 缺失,白骨精血量无法按波次计算'); return 1; } let waveIndex = ctx.session.spawner?.waveIndex ?? 0; if (waveIndex < 0) waveIndex = 0; if (waveIndex >= waves.length) waveIndex = waves.length - 1; const wave = waves[waveIndex]; let weightedSum = 0; let totalCount = 0; for (const port of wave.ports ?? []) { for (const seg of port.segments ?? []) { const hp = Math.max(0, Number(seg.maxHp) || 0); const count = Math.max(0, Number(seg.count) || 0); if (hp <= 0 || count <= 0) continue; weightedSum += hp * count; totalCount += count; } } if (totalCount > 0) { return Math.max(1, Math.floor(weightedSum / totalCount)); } // 波次段尚未写入 maxHp 时:取场上存活普通怪 maxHp 平均(同波存活怪) const world = ctx.session.world; let liveSum = 0; let liveCount = 0; world.forEachAlive((id) => { if (!isHealCandidate(world, id)) return; const health = world.getComponent(id, HealthComp); if (!health || health.maxHp <= 0) return; liveSum += health.maxHp; liveCount++; }); if (liveCount > 0) { return Math.max(1, Math.floor(liveSum / liveCount)); } console.error( '[MechanismServices] 当前波无可用怪物血量,白骨精 HP=1 waveIndex=', waveIndex, ); return 1; } /** 机制单位落点:怪物最多出怪口区域内随机可通行格(白骨精/女巫等;飞禽空投不走此函数) */ export function pickRandomSpawnGrid( ctx: MechanismContext, rowRange?: { startRow: number; endRow: number }, ): MapGrid | null { const map = ctx.runtime.Map; if (!map) return null; let candidates = map.getAllCanWalkMapGrids(); if (rowRange) { candidates = candidates.filter((g) => isRowInRange(g.Row, rowRange)); } if (candidates.length === 0) return null; const inBusiestArea = filterGridsByBusiestMonsterArea(ctx.session, candidates); const shuffled = shuffleMapGrids(inBusiestArea); return shuffled[0] ?? null; } /** 机制演出落点:怪物最多出怪口区域内随机可开挖格(狐狸精/蜘蛛精/黄风怪等) */ export function pickRandomDigGrid(ctx: MechanismContext, rowRange?: MapRowRange): MapGrid | null { const map = ctx.runtime.Map; if (!map) return null; let candidates = map.getAllCanBeDigMapGrids().filter( (g) => g.isCanBeDig && (!rowRange || isRowInRange(g.Row, rowRange)), ); if (candidates.length === 0) return null; const inBusiestArea = filterGridsByBusiestMonsterArea(ctx.session, candidates); const shuffled = shuffleMapGrids(inBusiestArea); return shuffled[0] ?? null; } /** 旧项目 RedMoonMultiplier:par1 为额外移速比例,最终移速 × (1 + accelerateSpeed) */ export function calcBloodMoonMoveSpeedMul(accelerateSpeed: number): number { return 1 + Math.max(0, accelerateSpeed); } export function applyBloodMoonAccelerate( ctx: MechanismContext, entityId: EntityId, accelerateSpeed: number, remainSec: number, ): void { const session = ctx.session; const world = session.world; if (!world.isAlive(entityId)) return; if (!isBloodMoonAccelerateCandidate(world, entityId)) return; if (!world.getComponent(entityId, AttributeComp)) { world.addComponent(entityId, new AttributeComp()); } session.attributes.addModifier(entityId, { attr: 'move_speed_mul', op: 'Mul', value: calcBloodMoonMoveSpeedMul(accelerateSpeed), source: BLOOD_MOON_MODIFIER_GROUP, group: BLOOD_MOON_MODIFIER_GROUP, remain: Math.max(0.05, remainSec), }); const presenter = ctx.runtime.getMonsterPresenter(); const node = presenter?.getEntityNodeMap().get(entityId); const view = node?.getComponent(BATTLE_MONSTER_CCCLASS) as { showAccelerateFx?: () => void; } | null; view?.showAccelerateFx?.(); getMonsterStatusText().play(entityId, '加速'); } export function clearBloodMoonAccelerate(ctx: MechanismContext, entityId: EntityId): void { const session = ctx.session; const world = session.world; if (!world.isAlive(entityId)) return; session.attributes.removeModifiersByGroup(entityId, BLOOD_MOON_MODIFIER_GROUP); const presenter = ctx.runtime.getMonsterPresenter(); const node = presenter?.getEntityNodeMap().get(entityId); const view = node?.getComponent(BATTLE_MONSTER_CCCLASS) as { clearAccelerateFx?: () => void; } | null; view?.clearAccelerateFx?.(); } export function applyBloodMoonAccelerateToAll( ctx: MechanismContext, accelerateSpeed: number, remainSec: number, ): void { const session = ctx.session; session.world.forEachAlive(id => { applyBloodMoonAccelerate(ctx, id, accelerateSpeed, remainSec); }); } export function clearBloodMoonAccelerateFromAll(ctx: MechanismContext): void { const session = ctx.session; session.world.forEachAlive(id => { clearBloodMoonAccelerate(ctx, id); }); } function isBloodMoonAccelerateCandidate( world: MechanismContext['session']['world'], id: EntityId, ): boolean { const team = world.getComponent(id, TeamComp); const tags = world.getComponent(id, TagComp); const isEnemy = (team && BattleTeamId.isEnemyTeam(team.team)) || tags?.tags.includes(EntityTag.Enemy); if (!isEnemy) return false; if (tags?.tags.includes(MECHANISM_UNIT_TAG)) return false; const health = world.getComponent(id, HealthComp); if (!health || health.hp <= 0) return false; const move = world.getComponent(id, MovementComp); if (!move) return false; return true; } /** 黄风怪机制表现:渐显 → 攻击 → 渐隐(收尾用动画时长 delay,不依赖 Spine complete) */ export function playBloodMoonVisual( ctx: MechanismContext, grid: MapGrid, onAttackComplete: () => void, ): boolean { const root = ctx.runtime.getMonsterPresenter()?.monsterRoot; if (!root?.isValid) return false; let node: Node | null = null; try { node = gg.res.getNode('prefab/monster/', BLOOD_MOON_MECHANISM_PREFAB, GBundle.BattleGameMonster); } catch { console.error('[MechanismServices] blood moon prefab missing', BLOOD_MOON_MECHANISM_PREFAB); return false; } if (!node?.isValid) return false; // 对象池回收后常为 active=false;不重新激活则 tween/Spine 不推进,二次触发会卡死 Tween.stopAllByTarget(node); const opacity = ensureMechanismUIOpacity(node); Tween.stopAllByTarget(opacity); node.active = true; node.parent = root; node.setWorldPosition(grid.node.worldPosition.clone()); opacity.opacity = 0; const animNode = node.getChildByName('anim'); const sk = animNode?.getComponent(sp.Skeleton) ?? null; if (!sk) { gg.res.putNode(node); return false; } const battleScale = Math.max(0.01, ctx.timeScale); BattleSpinePlayback.clearPendingComplete(sk); sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.REALTIME); sk.timeScale = battleScale; let finished = false; const finishVisual = () => { if (finished) return; finished = true; if (node?.isValid) { Tween.stopAllByTarget(node); const uo = node.getComponent(UIOpacity); if (uo) Tween.stopAllByTarget(uo); gg.res.putNode(node); } }; tween(opacity) .to(MECHANISM_FADE_IN_SEC, { opacity: 255 }) .call(() => { if (!isValid(node) || !isValid(sk)) { onAttackComplete(); finishVisual(); return; } sk.timeScale = battleScale; const entry = sk.setAnimation(0, BLOOD_MOON_ATTACK_ANIM, false); const attackSec = (entry?.animationEnd ?? sk.findAnimation(BLOOD_MOON_ATTACK_ANIM)?.duration ?? 1.2) / battleScale; // 攻击播完即结算加速;勿等 Spine complete(部分环境不回调会卡死二次触发) tween(node!) .delay(Math.max(0.05, attackSec)) .call(() => { onAttackComplete(); if (!isValid(node)) { finishVisual(); return; } const uo = ensureMechanismUIOpacity(node!); tween(uo) .to(MECHANISM_FADE_OUT_SEC, { opacity: 0 }) .call(finishVisual) .start(); }) .start(); }) .start(); return true; } function gridHasActiveSpiderSilk(grid: MapGrid): boolean { const silk = grid.node?.getChildByName('spiderSilk'); return !!silk?.isValid && silk.active; } /** 蜘蛛精吐丝目标:可通行、可摆 type3、放置后路径仍通 */ export function pickRandomSpiderWebTarget(ctx: MechanismContext): MapGrid | null { const map = ctx.runtime.Map; if (!map) return null; const candidates = map.getAllCanWalkMapGrids().filter( (grid) => grid.isCanPutGrid && !grid.hasSpiderWeb && !gridHasActiveSpiderSilk(grid) && !grid.ShowGuidePos && map.canPlaceType3At(grid), ); if (candidates.length === 0) return null; return candidates[Math.floor(Math.random() * candidates.length)]; } /** 蛛网落地:禁止背包 type3 放置,不影响怪物通行 */ export function applySpiderWebToGrid(ctx: MechanismContext, grid: MapGrid): void { grid.hasSpiderWeb = true; grid.isCanPutGrid = false; const cell = mapGridToLogicCell(grid.Row, grid.Col, ctx.session.grid.rows, ctx.session.grid.cols); if (cell) { ctx.session.grid.setBuildable(cell, grid.isCanPutText || grid.isCanPutGrid); } } /** 吐丝失败时撤销占位(仅当该格无有效持续蛛网表现) */ function clearSpiderWebFromGrid(ctx: MechanismContext, grid: MapGrid): void { if (!grid.hasSpiderWeb) return; const map = ctx.runtime.Map; if (gridHasActiveSpiderSilk(grid)) return; grid.hasSpiderWeb = false; if (map?.canPlaceType3At(grid)) { grid.isCanPutGrid = true; } const cell = mapGridToLogicCell(grid.Row, grid.Col, ctx.session.grid.rows, ctx.session.grid.cols); if (cell) { ctx.session.grid.setBuildable(cell, grid.isCanPutText || grid.isCanPutGrid); } } function destroySpiderSilkOnGrid(grid: MapGrid): void { const silk = grid.node?.getChildByName('spiderSilk'); if (!silk?.isValid) return; Tween.stopAllByTarget(silk); const sk = silk.getComponent(sp.Skeleton); if (sk) BattleSpinePlayback.clearPendingComplete(sk); silk.destroy(); } function ensureMechanismUIOpacity(node: Node): UIOpacity { return node.getComponent(UIOpacity) ?? node.addComponent(UIOpacity); } function resetSpiderSpiritPoolNode(node: Node): void { Tween.stopAllByTarget(node); const uo = node.getComponent(UIOpacity); if (uo) Tween.stopAllByTarget(uo); node.active = true; const tpl = node.getChildByName('flyTemplate'); if (tpl?.isValid) tpl.active = false; } function aimNodeAtWorldTarget(node: Node, fromWorld: Readonly, toWorld: Readonly): void { const dx = toWorld.x - fromWorld.x; const dy = toWorld.y - fromWorld.y; if (dx * dx + dy * dy < 1e-4) return; node.angle = Math.atan2(dy, dx) * 180 / Math.PI; } function attachNodeWorldPosToGrid(node: Node, grid: MapGrid): void { const world = node.worldPosition.clone(); node.parent = grid.node; node.setWorldPosition(world); } /** 复制 flyTemplate(模板始终隐藏)→ 飞行 → 目的地命中 → 持续蛛网 */ function launchSpiderSilk( ctx: MechanismContext, spiderNode: Node, targetGrid: MapGrid, ): void { if (!targetGrid.node?.isValid) return; if (targetGrid.hasSpiderWeb || gridHasActiveSpiderSilk(targetGrid)) return; // 飞行期间即占位,避免连续触发时同一格被重复选中 applySpiderWebToGrid(ctx, targetGrid); const root = ctx.runtime.getMonsterPresenter()?.monsterRoot; const template = spiderNode.getChildByName('flyTemplate'); if (!root?.isValid || !template?.isValid) { console.log('[MechanismServices] spider silk template missing'); clearSpiderWebFromGrid(ctx, targetGrid); return; } destroySpiderSilkOnGrid(targetGrid); const silk = instantiate(template); silk.name = 'spiderSilk'; silk.active = true; silk.parent = root; silk.setWorldPosition(template.worldPosition); const sk = silk.getComponent(sp.Skeleton); if (!sk) { silk.destroy(); clearSpiderWebFromGrid(ctx, targetGrid); return; } const battleScale = Math.max(0.01, ctx.timeScale); sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.REALTIME); BattleSpinePlayback.clearPendingComplete(sk); sk.clearTracks(); sk.setToSetupPose(); sk.timeScale = battleScale; sk.setAnimation(0, SPIDER_SILK_FLY_ANIM, true); const start = silk.worldPosition.clone(); const end = targetGrid.node.worldPosition.clone(); aimNodeAtWorldTarget(silk, start, end); const dist = Vec3.distance(start, end); const duration = Math.min( SPIDER_SILK_FLY_MAX_SEC, Math.max(SPIDER_SILK_FLY_MIN_SEC, dist / SPIDER_SILK_FLY_SPEED), ); tween(silk) .to(duration, { worldPosition: end }) .call(() => { if (!isValid(silk) || !targetGrid.node?.isValid) { if (isValid(silk)) silk.destroy(); clearSpiderWebFromGrid(ctx, targetGrid); return; } Tween.stopAllByTarget(silk); attachNodeWorldPosToGrid(silk, targetGrid); silk.angle = 0; BattleSpinePlayback.clearPendingComplete(sk); sk.timeScale = battleScale; const hitEntry = sk.setAnimation(0, SPIDER_SILK_HIT_ANIM, false); const hitSec = (hitEntry?.animationEnd ?? sk.findAnimation(SPIDER_SILK_HIT_ANIM)?.duration ?? 0.5) / battleScale; // 勿依赖 Spine complete(部分环境不回调会卡在击中帧);对齐黄风怪用时长推进 tween(silk) .delay(Math.max(0.05, hitSec)) .call(() => { if (!isValid(silk)) return; sk.timeScale = battleScale; sk.setAnimation(0, SPIDER_SILK_LOOP_ANIM, true); }) .start(); }) .start(); } /** 蜘蛛精机制表现:可开挖格渐显 → 攻击吐丝 → 渐隐(出现规则同黄风怪) */ export function playSpiderSpiritVisual( ctx: MechanismContext, appearGrid: MapGrid, targetGrid: MapGrid, onComplete: () => void, ): boolean { const root = ctx.runtime.getMonsterPresenter()?.monsterRoot; if (!root?.isValid) return false; let node: Node | null = null; let finished = false; const finish = () => { if (finished) return; finished = true; if (node?.isValid) { resetSpiderSpiritPoolNode(node); gg.res.putNode(node); } onComplete(); }; try { node = gg.res.getNode('prefab/monster/', SPIDER_SPIRIT_MECHANISM_PREFAB, GBundle.BattleGameMonster); } catch { console.error('[MechanismServices] spider spirit prefab missing', SPIDER_SPIRIT_MECHANISM_PREFAB); return false; } if (!node?.isValid) return false; resetSpiderSpiritPoolNode(node); node.parent = root; node.setWorldPosition(appearGrid.node.worldPosition.clone()); const opacity = ensureMechanismUIOpacity(node); opacity.opacity = 0; const animNode = node.getChildByName('anim'); const sk = animNode?.getComponent(sp.Skeleton) ?? null; if (!sk) { finish(); return false; } const battleScale = ctx.timeScale; sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.REALTIME); sk.timeScale = battleScale; sk.setAnimation(0, SPIDER_SPIRIT_IDLE_ANIM, true); const fadeOut = () => { if (!isValid(node)) { finish(); return; } tween(opacity) .to(MECHANISM_FADE_OUT_SEC, { opacity: 0 }) .call(finish) .start(); }; tween(opacity) .to(MECHANISM_FADE_IN_SEC, { opacity: 255 }) .call(() => { if (!isValid(node)) { finish(); return; } BattleAudio.playSpiderSpiritCast(); launchSpiderSilk(ctx, node!, targetGrid); const entry = sk.setAnimation(0, SPIDER_SPIRIT_ATTACK_ANIM, false); const attackSec = (entry?.animationEnd ?? sk.findAnimation(SPIDER_SPIRIT_ATTACK_ANIM)?.duration ?? 2) / Math.max(0.01, battleScale); tween(node!) .delay(attackSec) .call(fadeOut) .start(); }) .start(); return true; } export function spawnWitchMechanismUnit( ctx: MechanismContext, grid: MapGrid, maxHp: number, ): EntityId | null { const session = ctx.session; const map = ctx.runtime.Map; const presenter = ctx.runtime.getMonsterPresenter(); if (!map || !presenter) { console.error('[MechanismServices] spawnWitch: map or presenter missing'); return null; } const monsterData = gg.data.project.getMonsterDataByID(WITCH_MECHANISM_MONSTER_ID); if (!monsterData) { console.error('[MechanismServices] witch monster data missing', WITCH_MECHANISM_MONSTER_ID); return null; } const config = gg.battle.config as TableBattleConfigProvider; const unitDefId = config.ensureMonsterUnitById(WITCH_MECHANISM_MONSTER_ID, maxHp); const def = session.config.getUnit(unitDefId); if (!def) { console.error('[MechanismServices] unit def missing', unitDefId); return null; } const anchor = mapGridToLogicCell(grid.Row, grid.Col, session.grid.rows, session.grid.cols); const targetPlayerId = session.players[0]?.id ?? 0; const entity = UnitFactory.create(session, def, targetPlayerId, anchor, [anchor]); if (!session.world.isAlive(entity)) return null; session.world.addComponent(entity, new TeamComp(BattleTeamId.enemyForPlayer(targetPlayerId), targetPlayerId)); const meta = session.world.getComponent(entity, UnitMetaComp); if (meta) meta.spawnPoint = 0; const tagComp = session.world.getComponent(entity, TagComp) ?? new TagComp([]); if (!tagComp.tags.includes(EntityTag.Enemy)) tagComp.tags.push(EntityTag.Enemy); if (!tagComp.tags.includes(MECHANISM_UNIT_TAG)) tagComp.tags.push(MECHANISM_UNIT_TAG); if (!tagComp.tags.includes(MECHANISM_WITCH_TAG)) tagComp.tags.push(MECHANISM_WITCH_TAG); session.world.addComponent(entity, tagComp); const health = session.world.getComponent(entity, HealthComp); if (health) { health.maxHp = maxHp; health.hp = maxHp; } // 与普通怪一致:脚底偏移后的格点,避免落在格心显得「偏上」 const endPos = applyMonsterFootOffset(grid.node.worldPosition); const move = session.world.getComponent(entity, MovementComp); if (move) { move.presentLocked = true; move.spawnProtected = true; move.reachedGoal = true; move.hasAttackGoal = false; // 不可攻唐僧,避免 reachedGoal 被当成开战 move.path = []; move.pathIndex = 0; move.speed = 0; } const tf = session.world.getComponent(entity, TransformComp); if (tf) { tf.row = grid.Row; tf.col = grid.Col; session.spatial.updateEntity(entity, { row: grid.Row, col: grid.Col }); const logical = session.grid.cellToWorld({ row: grid.Row, col: grid.Col }); if (move) { move.worldX = logical.x; move.worldY = logical.y; } } if (!presenter.spawnMechanismDropIn(entity, monsterData, endPos, maxHp)) { despawnMechanismUnit(ctx, entity); return null; } return entity; } /** 即将出怪波次索引:未开战→0;波内出怪中→当前波;波间空隙→下一波 */ export function getUpcomingWaveIndex(ctx: MechanismContext): number { const waves = ctx.session.levelDef?.waves; if (!waves?.length) return 0; const spawner = ctx.session.spawner; const waveIndex = spawner?.waveIndex ?? -1; if (waveIndex < 0) return 0; if (spawner?.isWaveCombatActive()) { return Math.min(waveIndex, waves.length - 1); } const next = waveIndex + 1; if (next < waves.length) return next; return waves.length - 1; } /** 即将出怪波次的难度行系数(取该波各段 difficultyRow 最大值) */ export function getUpcomingWaveDifficultyRow(ctx: MechanismContext): number { const waves = ctx.session.levelDef?.waves; if (!waves?.length) return 1; const wave = waves[getUpcomingWaveIndex(ctx)]; let row = 1; for (const port of wave.ports ?? []) { for (const seg of port.segments ?? []) { const r = Number(seg.difficultyRow) || 1; if (r > row) row = r; } } return Math.max(1, row); } /** 飞禽血量:按「即将要出的波次」difficultyRow 套章节血量公式 */ export function calcParatroopMonsterHp(ctx: MechanismContext, monsterId: number): number { const monsterConfig = readMonsterConfigFromLevel(ctx.session.levelDef.extra); if (!monsterConfig) { console.error('[MechanismServices] monsterConfig missing,飞禽血量无法按波次系数计算'); return Math.max(1, gg.data.project.getMonsterDataByID(monsterId)?.HP ?? 1); } const difficultyRow = getUpcomingWaveDifficultyRow(ctx); return calcChapterMonsterHp(monsterId, 1, difficultyRow, monsterConfig); } /** 空投落点格:取最近出怪口 port,供 MovementSystem 寻路阵营区域 */ function inferNearestSpawnPort(topology: MapTopology, row: number, col: number): number { let bestPort = 1; let bestDist = Number.POSITIVE_INFINITY; for (const [port, cell] of topology.spawnPorts) { const dr = cell.row - row; const dc = cell.col - col; const d = dr * dr + dc * dc; if (d < bestDist) { bestDist = d; bestPort = port; } } return bestPort; } /** 空投飞禽:逻辑层落格 + 表现层从云端降落,落地后寻路 */ export function spawnParatroopAtGrid( ctx: MechanismContext, grid: MapGrid, monsterId: number, ): EntityId | null { const session = ctx.session; const map = ctx.runtime.Map; const presenter = ctx.runtime.getMonsterPresenter(); if (!map || !presenter) return null; const monsterData = gg.data.project.getMonsterDataByID(monsterId); if (!monsterData) { console.error('[MechanismServices] paratroop monster missing', monsterId); return null; } const difficultyRow = getUpcomingWaveDifficultyRow(ctx); const maxHp = calcParatroopMonsterHp(ctx, monsterId); const unitDefId = makeMonsterUnitDefId(monsterId, 1, difficultyRow); const config = gg.battle.config as TableBattleConfigProvider; config.ensureMonsterUnitDef(unitDefId, maxHp); const def = session.config.getUnit(unitDefId); if (!def) return null; const anchor = mapGridToLogicCell(grid.Row, grid.Col, session.grid.rows, session.grid.cols); if (!anchor) return null; const targetPlayerId = session.players[0]?.id ?? 0; const entity = UnitFactory.create(session, def, targetPlayerId, anchor, [anchor]); if (!session.world.isAlive(entity)) return null; session.world.addComponent(entity, new TeamComp(BattleTeamId.enemyForPlayer(targetPlayerId), targetPlayerId)); const meta = session.world.getComponent(entity, UnitMetaComp); if (meta) { meta.spawnPoint = inferNearestSpawnPort(session.topology, grid.Row, grid.Col); // 芭蕉扇后退目标 = 落点格(不是出怪口) meta.homeRow = grid.Row; meta.homeCol = grid.Col; } const tagComp = session.world.getComponent(entity, TagComp) ?? new TagComp([]); if (!tagComp.tags.includes(EntityTag.Enemy)) tagComp.tags.push(EntityTag.Enemy); if (!tagComp.tags.includes(MECHANISM_PARATROOP_TAG)) tagComp.tags.push(MECHANISM_PARATROOP_TAG); session.world.addComponent(entity, tagComp); const health = session.world.getComponent(entity, HealthComp); if (health) { health.maxHp = maxHp; health.hp = maxHp; } // 脚底偏移,避免「刷到上方」停在格心/下落起点 const endPos = applyMonsterFootOffset(grid.node.worldPosition); const move = session.world.getComponent(entity, MovementComp); if (move) { move.presentLocked = true; move.spawnProtected = true; move.reachedGoal = false; move.path = []; move.pathIndex = 0; } const tf = session.world.getComponent(entity, TransformComp); if (tf) { tf.row = grid.Row; tf.col = grid.Col; session.spatial.updateEntity(entity, { row: grid.Row, col: grid.Col }); const logical = session.grid.cellToWorld({ row: grid.Row, col: grid.Col }); if (move) { move.worldX = logical.x; move.worldY = logical.y; } } session.pathfinder.rebuild(session.topology); session.movement.assignPath(entity); if (!presenter.spawnParatroopDropIn(entity, monsterData, endPos, maxHp, () => { releaseParatroopAfterLand(ctx, entity, endPos); })) { despawnMechanismUnit(ctx, entity); return null; } return entity; } /** 空投落地:同步逻辑格、规划路径、刷新表现(与普通出怪后 assignPath 等价) */ export function releaseParatroopAfterLand( ctx: MechanismContext, entityId: EntityId, landWorldPos: Readonly<{ x: number; y: number }>, ): void { const session = ctx.session; const map = ctx.runtime.Map; if (!map) return; const world = session.world; if (!world.isAlive(entityId)) return; const tf = world.getComponent(entityId, TransformComp); const move = world.getComponent(entityId, MovementComp); if (!tf || !move) return; session.pathfinder.rebuild(session.topology); let mapGrid = map.getMapGridByWorldPos(v3(landWorldPos.x, landWorldPos.y, 0)); const fromCell = mapGrid ? { row: mapGrid.Row, col: mapGrid.Col } : { row: tf.row, col: tf.col }; const walkableGrid = findNearestCurrentlyWalkableCell( map, session, fromCell, (cell) => isParatroopForbiddenNearTangSeng(map, cell), ); if (walkableGrid) { mapGrid = walkableGrid; tf.row = mapGrid.Row; tf.col = mapGrid.Col; session.spatial.updateEntity(entityId, { row: mapGrid.Row, col: mapGrid.Col }); const meta = world.getComponent(entityId, UnitMetaComp); if (meta) { meta.homeRow = mapGrid.Row; meta.homeCol = mapGrid.Col; } } const logical = session.grid.cellToWorld({ row: tf.row, col: tf.col }); move.worldX = logical.x; move.worldY = logical.y; move.presentLocked = false; move.spawnProtected = false; move.reachedGoal = false; move.path = []; move.pathIndex = 0; move.hasAttackGoal = false; move.forcePresentSync = true; session.movement.assignPath(entityId); const presenter = ctx.runtime.getMonsterPresenter(); const node = presenter?.getEntityNodeMap().get(entityId); if (node?.isValid && mapGrid) { const footPos = applyMonsterFootOffset(mapGrid.node.worldPosition); node.setWorldPosition(footPos.x, footPos.y, footPos.z); } if (!node?.isValid) return; syncMonsterNode(session, map, entityId, node); const view = node.getComponent(BATTLE_MONSTER_CCCLASS) as { resolveAttackTarget?: () => void; updateLandingBornPos?: (pos: import('cc').Vec3) => void; state?: MonsterState; playWalk?: () => void; } | null; view?.updateLandingBornPos?.(node.worldPosition.clone()); view?.resolveAttackTarget?.(); if (move.reachedGoal && move.hasAttackGoal) { if (view) view.state = MonsterState.ATTACKING; } else if (!move.reachedGoal && move.path.length > 0) { if (view) view.state = MonsterState.MOVING_FORWARD; view?.playWalk?.(); } else { console.log('[MechanismServices] paratroop no path after land, retry', entityId, tf.row, tf.col); session.movement.assignPath(entityId); if (move.reachedGoal && move.hasAttackGoal) { if (view) view.state = MonsterState.ATTACKING; } else if (!move.reachedGoal && move.path.length > 0) { if (view) view.state = MonsterState.MOVING_FORWARD; view?.playWalk?.(); } else if (view) { view.state = MonsterState.STOP; } } } /** * 机制单位落点同步:空间索引 + 逻辑坐标 + 解除出生保护,确保可被索敌/命中。 * @param cocosWorldPos 表现层 Cocos 世界坐标(仅用于反查 MapGrid) */ export function finalizeMechanismUnitPlacement( session: BattleSession, map: MapManager, entityId: EntityId, cocosWorldPos: Readonly<{ x: number; y: number }>, ): void { const world = session.world; if (!world.isAlive(entityId)) return; const mapGrid = map.getMapGridByWorldPos(v3(cocosWorldPos.x, cocosWorldPos.y, 0)); const tf = world.getComponent(entityId, TransformComp); if (mapGrid && tf) { tf.row = mapGrid.Row; tf.col = mapGrid.Col; session.spatial.updateEntity(entityId, { row: mapGrid.Row, col: mapGrid.Col }); } const move = world.getComponent(entityId, MovementComp); if (!move || !tf) return; // 逻辑层 worldX/Y 须为 GridMap 坐标(Y 向下),与塔/常规怪一致 const logical = session.grid.cellToWorld({ row: tf.row, col: tf.col }); move.worldX = logical.x; move.worldY = logical.y; move.presentLocked = true; move.reachedGoal = true; move.hasAttackGoal = false; move.path = []; move.pathIndex = 0; move.speed = 0; move.spawnProtected = false; } export function despawnMechanismUnit(ctx: MechanismContext, entityId: EntityId): void { if (!ctx.session.world.isAlive(entityId)) return; ctx.session.lifecycle.despawn(entityId); } /** 旧 SingleWitch:按 maxHp 系数回血,最多 healCount 只 */ export function healRandomMonsters( ctx: MechanismContext, healCoeff: number, healCount: number, excludeEntityId: EntityId, ): EntityId[] { const session = ctx.session; const world = session.world; const presenter = ctx.runtime.getMonsterPresenter(); const candidates: EntityId[] = []; world.forEachAlive(id => { if (id === excludeEntityId) return; if (!isHealCandidate(world, id)) return; candidates.push(id); }); // 兜底:从表现层节点收集(与旧 SingleWitch.getAllMonster 一致) if (candidates.length === 0 && presenter) { for (const node of presenter.getAllMonsterNodes()) { if (!node?.isValid) continue; const view = node.getComponent(BATTLE_MONSTER_CCCLASS) as { entityId?: EntityId | null; IsDead?: boolean; Data?: { id?: number }; } | null; const entityId = view?.entityId; if (entityId == null || entityId === excludeEntityId) continue; if (view?.IsDead) continue; if (view?.Data?.id === WITCH_MECHANISM_MONSTER_ID) continue; if (!world.isAlive(entityId)) continue; if (!isHealCandidate(world, entityId)) continue; if (!candidates.includes(entityId)) candidates.push(entityId); } } const picked = pickRandomIds(candidates, Math.min(healCount, candidates.length)); for (const id of picked) { applyMonsterHeal(ctx, id, healCoeff); } return picked; } function isHealCandidate( world: MechanismContext['session']['world'], id: EntityId, ): boolean { const team = world.getComponent(id, TeamComp); const tags = world.getComponent(id, TagComp); const isEnemy = (team && BattleTeamId.isEnemyTeam(team.team)) || tags?.tags.includes(EntityTag.Enemy); if (!isEnemy) return false; if (tags?.tags.includes(MECHANISM_UNIT_TAG)) return false; const health = world.getComponent(id, HealthComp); if (!health || health.hp <= 0) return false; const meta = world.getComponent(id, UnitMetaComp); if (!meta?.unitDefId) return false; return true; } function pickRandomIds(ids: EntityId[], count: number): EntityId[] { if (count <= 0 || ids.length === 0) return []; const pool = ids.slice(); for (let i = pool.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [pool[i], pool[j]] = [pool[j], pool[i]]; } return pool.slice(0, count); } function applyMonsterHeal(ctx: MechanismContext, entityId: EntityId, healCoeff: number): void { const world = ctx.session.world; const health = world.getComponent(entityId, HealthComp); if (!health) return; const amount = Math.max(1, Math.floor(health.maxHp * healCoeff)); health.hp = Math.min(health.maxHp, health.hp + amount); const presenter = ctx.runtime.getMonsterPresenter(); const node = presenter?.getEntityNodeMap().get(entityId); const view = node?.getComponent(BATTLE_MONSTER_CCCLASS) as { syncMxHp?: (h: number, m: number) => void; playHealEffect?: () => void; } | null; if (view?.syncMxHp) { view.syncMxHp(health.hp, health.maxHp); view.playHealEffect?.(); } } export function isCombatPhase(ctx: MechanismContext): boolean { return ctx.session.phase === BattlePhase.COMBAT; } /** 魅惑可选目标:地图上已激活攻击的武器/英雄 */ export interface CharmTarget { entityId: EntityId; composeKey: string; gridIndices: readonly number[]; anchorGridIndex: number; } export function getCharmTargetWorldCenter(ctx: MechanismContext, target: CharmTarget): Vec3 { const map = ctx.runtime.Map; if (!map) return v3(); const sum = v3(); let count = 0; for (const idx of target.gridIndices) { const grid = map.getMapGridByIndex(idx); if (!grid?.node?.isValid) continue; sum.add(grid.node.worldPosition); count++; } if (count <= 0) { const fallback = map.getMapGridByIndex(target.anchorGridIndex); return fallback?.node?.worldPosition.clone() ?? v3(); } return sum.multiplyScalar(1 / count); } /** 已激活攻击:单字武器或已成形的多格英雄(排除睡觉中的组合字) */ function isActivatedAttackComposition(comp: ComposedUnit): boolean { if (comp.weapon1Id <= 0) return false; WeaponComposeConfigProvider.instance.load(); const provider = WeaponComposeConfigProvider.instance; if (provider.isSingleCellWeapon1(comp.weapon1Id)) return true; return comp.gridIndices.length >= 2; } export function pickRandomCharmTargets( ctx: MechanismContext, count: number, excludeKeys: ReadonlyMembershipSet, ): CharmTarget[] { const runtime = ctx.runtime; const player = runtime.getPlayer(0); const map = runtime.Map; if (!player || !map || count <= 0) return []; const session = ctx.session; const loadoutSvc = getTowerLoadoutService(); const compositions = runtime.bagSession?.compositions ?? player.compositions; const seenKeys = createMembershipSet(); const candidates: CharmTarget[] = []; const tryAdd = (comp: ComposedUnit, entityId: EntityId): void => { if (!isActivatedAttackComposition(comp)) return; const key = composeKey(comp); if (excludeKeys.has(key) || seenKeys.has(key)) return; if (!session.world.isAlive(entityId)) return; const abilities = session.world.getComponent(entityId, AbilitySetComp); if (!abilities?.abilityIds.length) return; if (!loadoutSvc.getLoadout(entityId)?.weapon1Id) return; membershipAdd(seenKeys, key); candidates.push({ entityId, composeKey: key, gridIndices: comp.gridIndices.slice(), anchorGridIndex: comp.gridIndices[0], }); }; for (const entityId of player.deployedUnits) { const comp = findCompositionForEntity(entityId, player, session, map); if (!comp) continue; tryAdd(comp, entityId); } if (candidates.length < count) { for (const comp of compositions) { let entityId: EntityId | null = null; for (const idx of comp.gridIndices) { entityId = findEntityIdByGridIndex(idx, session, map, player); if (entityId != null) break; } if (entityId == null) continue; tryAdd(comp, entityId); } } for (let i = candidates.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [candidates[i], candidates[j]] = [candidates[j], candidates[i]]; } return candidates.slice(0, count); } function resetFoxSpiritPoolNode(node: Node): void { Tween.stopAllByTarget(node); const uo = node.getComponent(UIOpacity); if (uo) Tween.stopAllByTarget(uo); node.active = true; const tpl = node.getChildByName('flyTemplate'); if (tpl?.isValid) tpl.active = false; } function launchFoxCharmFly( ctx: MechanismContext, foxNode: Node, target: CharmTarget, durationSec: number, charmController: CharmController, onComplete: () => void, ): void { const root = ctx.runtime.getMonsterPresenter()?.monsterRoot; const template = foxNode.getChildByName('flyTemplate'); if (!root?.isValid || !template?.isValid) { console.log('[MechanismServices] fox charm flyTemplate missing'); onComplete(); return; } template.active = false; const charmFx = instantiate(template); charmFx.name = 'foxCharmFly'; charmFx.active = true; charmFx.parent = root; charmFx.setWorldPosition(foxNode.worldPosition.clone()); const hitNode = charmFx.getChildByName('击中效果'); const progressNode = charmFx.getChildByName('progressTemplate'); const flyNode = charmFx.getChildByName('fly'); if (hitNode?.isValid) hitNode.active = false; if (progressNode?.isValid) progressNode.active = false; if (flyNode?.isValid) flyNode.active = true; const start = charmFx.worldPosition.clone(); const end = getCharmTargetWorldCenter(ctx, target); const dist = Vec3.distance(start, end); const flySec = Math.min( FOX_CHARM_FLY_MAX_SEC, Math.max(FOX_CHARM_FLY_MIN_SEC, dist / FOX_CHARM_FLY_SPEED), ); tween(charmFx) .to(flySec, { worldPosition: end }) .call(() => { if (!isValid(charmFx)) { onComplete(); return; } if (flyNode?.isValid) flyNode.active = false; charmController.applyCharm(target, durationSec, charmFx); onComplete(); }) .start(); } /** 狐狸精魅惑表现:可开挖格从天而降 → 攻击 → 克隆 flyTemplate 飞向目标 */ export function playFoxSpiritCharmVisual( ctx: MechanismContext, appearGrid: MapGrid, target: CharmTarget, durationSec: number, charmController: CharmController, onComplete: () => void, ): boolean { const root = ctx.runtime.getMonsterPresenter()?.monsterRoot; if (!root?.isValid) return false; let node: Node | null = null; let finished = false; const finish = () => { if (finished) return; finished = true; if (node?.isValid) { resetFoxSpiritPoolNode(node); gg.res.putNode(node); } onComplete(); }; try { node = gg.res.getNode('prefab/monster/', FOX_SPIRIT_MECHANISM_PREFAB, GBundle.BattleGameMonster); } catch { console.error('[MechanismServices] fox spirit prefab missing', FOX_SPIRIT_MECHANISM_PREFAB); return false; } if (!node?.isValid) return false; resetFoxSpiritPoolNode(node); node.parent = root; const landPos = appearGrid.node.worldPosition.clone(); node.setWorldPosition(landPos.clone().add(v3(0, MECHANISM_DROP_OFFSET_Y, 0))); const opacity = ensureMechanismUIOpacity(node); opacity.opacity = 0; const animNode = node.getChildByName('anim'); const sk = animNode?.getComponent(sp.Skeleton) ?? null; if (!sk) { finish(); return false; } const battleScale = ctx.timeScale; sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.REALTIME); sk.timeScale = battleScale; sk.setAnimation(0, FOX_SPIRIT_IDLE_ANIM, true); const fadeOut = () => { if (!isValid(node)) { finish(); return; } tween(opacity) .to(MECHANISM_FADE_OUT_SEC, { opacity: 0 }) .call(finish) .start(); }; tween(node) .parallel( tween(opacity).to(MECHANISM_FADE_IN_SEC, { opacity: 255 }), tween(node).to( MECHANISM_DROP_DURATION_SEC, { worldPosition: landPos }, { easing: easing.quadOut }, ), ) .call(() => { if (!isValid(node)) { finish(); return; } BattleAudio.playFoxSpiritCast(); const entry = sk.setAnimation(0, FOX_SPIRIT_ATTACK_ANIM, false); const attackSec = (entry?.animationEnd ?? sk.findAnimation(FOX_SPIRIT_ATTACK_ANIM)?.duration ?? 2) / Math.max(0.01, battleScale); tween(node) .delay(attackSec) .call(() => { if (!isValid(node)) { finish(); return; } launchFoxCharmFly(ctx, node!, target, durationSec, charmController, fadeOut); }) .start(); }) .start(); return true; }