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/mechanism/MechanismServices.ts

1312 lines
47 KiB

3 weeks ago
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<Vec3>, toWorld: Readonly<Vec3>): 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,飞禽血量无法按波次系数计算');
1 week ago
return Math.max(1, gg.data.project.getMonsterDataByID(monsterId)?.hp ?? 1);
3 weeks ago
}
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<string>,
): 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<string>();
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;
}