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.
1311 lines
47 KiB
1311 lines
47 KiB
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,飞禽血量无法按波次系数计算');
|
|
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<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;
|
|
}
|
|
|