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.
429 lines
16 KiB
429 lines
16 KiB
|
3 weeks ago
|
import { Node, sp } from 'cc';
|
||
|
|
import type { EntityId } from 'db://assets/mx/module/ecs';
|
||
|
|
import {
|
||
|
|
BattleEventFactory,
|
||
|
|
BattleTeamId,
|
||
|
|
BuffContainerComp,
|
||
|
|
HealthComp,
|
||
|
|
MovementComp,
|
||
|
|
TeamComp,
|
||
|
|
TransformComp,
|
||
|
|
} from 'db://assets/mx/module/battle';
|
||
|
|
import { syncMonsterNode } from '../../battle/bridge/CocosBattleMovementBridge';
|
||
|
|
import { GBundle } from '../../game/ConfigRes';
|
||
|
|
import { BATTLE_HERO_BUFF_PATH } from '../../battle/res/BattleHeroResCollector';
|
||
|
|
import { isEnemyMonsterWalkGrid } from '../../battle/map/MapPathQueries';
|
||
|
|
import { BattleSpinePlayback } from '../../battle/presentation/BattleSpinePlayback';
|
||
|
|
import { schedulePresentation } from '../../battle/skill/BattlePresentationClock';
|
||
|
|
import type { ProjectBattleHandle } from '../../battle/runtime/BattleRuntime';
|
||
|
|
import type { MapManager } from '../../battle/map/legacy/MapManager';
|
||
|
|
|
||
|
|
/** 陷阱预制体(battleHero/prefab/buff/三味真火,资源名与表 icon「三昧真火」不同) */
|
||
|
|
const FIRE_TRAP_PREFAB_DIR = BATTLE_HERO_BUFF_PATH;
|
||
|
|
const FIRE_TRAP_PREFAB_NAME = '三味真火';
|
||
|
|
/** 布置后循环播放 */
|
||
|
|
const FIRE_TRAP_ANIM_IDLE = '持续';
|
||
|
|
/** 兵将到达格心触发 */
|
||
|
|
const FIRE_TRAP_ANIM_BURST = '持续3';
|
||
|
|
/** 局内伤害类型 */
|
||
|
|
const FIRE_TRAP_DAMAGE_TYPE = 'fire_trap';
|
||
|
|
/** 无施法者时的占位 caster */
|
||
|
|
const FIRE_TRAP_CASTER = 0 as EntityId;
|
||
|
|
/** 三昧真火定身 debuff(MovementSystem 移速归零) */
|
||
|
|
const FIRE_TRAP_IMMOBILIZE_BUFF_ID = 'debuff_fire_trap';
|
||
|
|
/** 播「持续3」后等待再飘血(逻辑秒) */
|
||
|
|
const FIRE_TRAP_DAMAGE_DELAY_SEC = 1;
|
||
|
|
/** 飘字后再击杀,避免死亡特效抢在伤害数字前(逻辑秒) */
|
||
|
|
const FIRE_TRAP_KILL_DELAY_SEC = 0.4;
|
||
|
|
/** 「持续3」时长读不到时的回收兜底(逻辑秒) */
|
||
|
|
const FIRE_TRAP_BURST_FALLBACK_SEC = 1.2;
|
||
|
|
|
||
|
|
/** 单格火焰陷阱 */
|
||
|
|
type FireTrapEntry = {
|
||
|
|
/** 地图格索引 */
|
||
|
|
gridIndex: number;
|
||
|
|
/** 行 */
|
||
|
|
row: number;
|
||
|
|
/** 列 */
|
||
|
|
col: number;
|
||
|
|
/** 伤害上限 */
|
||
|
|
maxDamage: number;
|
||
|
|
/** 剩余持续时间(秒) */
|
||
|
|
remainSec: number;
|
||
|
|
/** 表现节点 */
|
||
|
|
node: Node;
|
||
|
|
/** 陷阱 Spine */
|
||
|
|
skel: sp.Skeleton | null;
|
||
|
|
/** 是否已进入触发序列(播持续3 / 等待伤害) */
|
||
|
|
triggering: boolean;
|
||
|
|
/** 是否已调度回收(防重复) */
|
||
|
|
releaseScheduled: boolean;
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 三昧真火(functiontype=24):怪物行走格布置陷阱,循环「持续」;
|
||
|
|
* 兵将到达格心播「持续3」并定身,1s 后飘血;致死延迟击杀以播死亡特效。
|
||
|
|
*/
|
||
|
|
export class BattleGameFireTrapController {
|
||
|
|
private _battle: ProjectBattleHandle | null = null;
|
||
|
|
private _map: MapManager | null = null;
|
||
|
|
/** 防守方 playerId(陷阱布置在进攻该方的行军路上) */
|
||
|
|
private _defenderPlayerId = 0;
|
||
|
|
private readonly _traps: FireTrapEntry[] = [];
|
||
|
|
/** 打断 pending 伤害/击杀调度 */
|
||
|
|
private _triggerGen = 0;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param battle 战斗句柄
|
||
|
|
* @param map 地图管理器
|
||
|
|
* @param defenderPlayerId 防守方;默认本机 0
|
||
|
|
*/
|
||
|
|
bind(battle: ProjectBattleHandle | null, map: MapManager | null, defenderPlayerId: number = 0): void {
|
||
|
|
this.unbind();
|
||
|
|
this._battle = battle;
|
||
|
|
this._map = map;
|
||
|
|
this._defenderPlayerId = Math.max(0, Math.floor(Number(defenderPlayerId) || 0));
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 释放全部陷阱 */
|
||
|
|
unbind(): void {
|
||
|
|
this._triggerGen++;
|
||
|
|
this._releaseAllVictims();
|
||
|
|
for (let i = 0; i < this._traps.length; i++) {
|
||
|
|
this._releaseTrapNode(this._traps[i].node, this._traps[i].skel);
|
||
|
|
}
|
||
|
|
this._traps.length = 0;
|
||
|
|
this._battle = null;
|
||
|
|
this._map = null;
|
||
|
|
this._defenderPlayerId = 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 帧更新:超时移除 / 检测格心触碰 */
|
||
|
|
tick(dt: number): void {
|
||
|
|
if (!this._battle || this._battle.session.ended) return;
|
||
|
|
const delta = Math.max(0, dt);
|
||
|
|
for (let i = this._traps.length - 1; i >= 0; i--) {
|
||
|
|
const trap = this._traps[i];
|
||
|
|
if (!trap.node?.isValid) {
|
||
|
|
this._traps.splice(i, 1);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if (trap.triggering) continue;
|
||
|
|
trap.remainSec -= delta;
|
||
|
|
if (trap.remainSec <= 0) {
|
||
|
|
this._releaseTrapNode(trap.node, trap.skel);
|
||
|
|
this._traps.splice(i, 1);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
const victim = this._findEnemyOnCell(trap.row, trap.col);
|
||
|
|
if (victim == null) continue;
|
||
|
|
this._beginTrigger(trap, victim);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 格上是否已有陷阱 */
|
||
|
|
hasTrapAt(gridIndex: number): boolean {
|
||
|
|
const idx = Number(gridIndex) || 0;
|
||
|
|
for (let i = 0; i < this._traps.length; i++) {
|
||
|
|
if (this._traps[i].gridIndex === idx) return true;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 布置陷阱
|
||
|
|
* @param gridIndex 地图格索引
|
||
|
|
* @param maxDamage 伤害上限(functionpar 第二段)
|
||
|
|
* @param durationSec 陷阱持续时间(functionpar 第三段)
|
||
|
|
* @returns 是否成功
|
||
|
|
*/
|
||
|
|
tryPlace(gridIndex: number, maxDamage: number, durationSec: number): boolean {
|
||
|
|
const map = this._map;
|
||
|
|
if (!map?.gridsInitialized || maxDamage <= 0 || durationSec <= 0) return false;
|
||
|
|
if (this.hasTrapAt(gridIndex)) return false;
|
||
|
|
|
||
|
|
const mg = map.getMapGridByIndex(gridIndex);
|
||
|
|
if (!mg?.node?.isValid || !mg.isCanWalk) return false;
|
||
|
|
const battle = this._battle;
|
||
|
|
if (!battle || !isEnemyMonsterWalkGrid(map, battle.session, mg, null, this._defenderPlayerId)) return false;
|
||
|
|
|
||
|
|
const spawned = this._spawnTrapNode(mg.node);
|
||
|
|
if (!spawned) return false;
|
||
|
|
|
||
|
|
this._traps.push({
|
||
|
|
gridIndex,
|
||
|
|
row: mg.Row,
|
||
|
|
col: mg.Col,
|
||
|
|
maxDamage,
|
||
|
|
remainSec: durationSec,
|
||
|
|
node: spawned.node,
|
||
|
|
skel: spawned.skel,
|
||
|
|
triggering: false,
|
||
|
|
releaseScheduled: false,
|
||
|
|
});
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 加载并挂到地图格,循环播「持续」 */
|
||
|
|
private _spawnTrapNode(parent: Node): { node: Node; skel: sp.Skeleton | null } | null {
|
||
|
|
if (!parent?.isValid) return null;
|
||
|
|
let node: Node | null = null;
|
||
|
|
try {
|
||
|
|
node = gg.res.getNode(FIRE_TRAP_PREFAB_DIR, FIRE_TRAP_PREFAB_NAME, GBundle.BattleHero);
|
||
|
|
} catch (e) {
|
||
|
|
console.log('[FireTrap] 三昧真火预制体加载失败', String(e));
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
if (!node?.isValid) {
|
||
|
|
console.log('[FireTrap] 三昧真火预制体缺失', FIRE_TRAP_PREFAB_NAME);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
node.parent = parent;
|
||
|
|
node.setPosition(0, 0, 0);
|
||
|
|
node.active = true;
|
||
|
|
|
||
|
|
const skel = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton);
|
||
|
|
if (skel?.isValid) {
|
||
|
|
skel.setAnimationCacheMode(sp.SpineAnimationCacheMode.REALTIME);
|
||
|
|
const ok = BattleSpinePlayback.play(skel, FIRE_TRAP_ANIM_IDLE, { loop: true });
|
||
|
|
if (!ok) {
|
||
|
|
console.log('[FireTrap] 陷阱待机动画缺失', FIRE_TRAP_ANIM_IDLE);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
console.log('[FireTrap] 陷阱节点缺少 sp.Skeleton');
|
||
|
|
}
|
||
|
|
return { node, skel: skel?.isValid ? skel : null };
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 释放陷阱节点 */
|
||
|
|
private _releaseTrapNode(node: Node | null | undefined, skel: sp.Skeleton | null | undefined): void {
|
||
|
|
if (skel?.isValid) {
|
||
|
|
BattleSpinePlayback.clearPendingComplete(skel);
|
||
|
|
}
|
||
|
|
if (!node?.isValid) return;
|
||
|
|
if (node['resKey']) {
|
||
|
|
gg.res.putNode(node);
|
||
|
|
} else {
|
||
|
|
node.destroy();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 查找已到达陷阱格中心的存活敌人(MovementSystem 抵达 path 格心才更新 TransformComp) */
|
||
|
|
private _findEnemyOnCell(row: number, col: number): EntityId | null {
|
||
|
|
const battle = this._battle;
|
||
|
|
if (!battle) return null;
|
||
|
|
const world = battle.session.world;
|
||
|
|
let found: EntityId | null = null;
|
||
|
|
world.forEachAlive((id) => {
|
||
|
|
if (found != null) return;
|
||
|
|
const team = world.getComponent(id, TeamComp);
|
||
|
|
if (!team || !BattleTeamId.isEnemyTeam(team.team)) return;
|
||
|
|
if (Math.max(0, Math.floor(Number(team.ownerId) || 0)) !== this._defenderPlayerId) return;
|
||
|
|
const move = world.getComponent(id, MovementComp);
|
||
|
|
if (move?.spawnProtected) return;
|
||
|
|
const tf = world.getComponent(id, TransformComp);
|
||
|
|
if (!tf || tf.row !== row || tf.col !== col) return;
|
||
|
|
found = id;
|
||
|
|
});
|
||
|
|
return found;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 格心触碰:定身 + 播「持续3」,1s 后结算伤害;陷阱等「持续3」播完再回收 */
|
||
|
|
private _beginTrigger(trap: FireTrapEntry, victim: EntityId): void {
|
||
|
|
const battle = this._battle;
|
||
|
|
if (!battle || trap.triggering) return;
|
||
|
|
trap.triggering = true;
|
||
|
|
|
||
|
|
if (!this._lockVictimOnTrap(victim, trap.row, trap.col)) {
|
||
|
|
console.log('[FireTrap] 定身失败 entity=', victim, 'grid=', trap.gridIndex);
|
||
|
|
trap.triggering = false;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const skel = trap.skel;
|
||
|
|
if (skel?.isValid) {
|
||
|
|
const ok = BattleSpinePlayback.play(skel, FIRE_TRAP_ANIM_BURST, {
|
||
|
|
loop: false,
|
||
|
|
onComplete: () => this._scheduleTrapRelease(trap),
|
||
|
|
});
|
||
|
|
if (!ok) {
|
||
|
|
console.log('[FireTrap] 陷阱爆发动画缺失', FIRE_TRAP_ANIM_BURST);
|
||
|
|
}
|
||
|
|
this._scheduleTrapReleaseFallback(trap, skel);
|
||
|
|
} else {
|
||
|
|
this._scheduleTrapRelease(trap);
|
||
|
|
}
|
||
|
|
|
||
|
|
const scheduleGen = this._triggerGen;
|
||
|
|
schedulePresentation(FIRE_TRAP_DAMAGE_DELAY_SEC, () => {
|
||
|
|
if (scheduleGen !== this._triggerGen) return;
|
||
|
|
this._applyTrapDamage(trap, victim, scheduleGen);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 定身并钉在格心:presentLocked + 定身 buff 覆盖至飘血/击杀全流程。
|
||
|
|
* @returns 是否成功
|
||
|
|
*/
|
||
|
|
private _lockVictimOnTrap(victim: EntityId, row: number, col: number): boolean {
|
||
|
|
const battle = this._battle;
|
||
|
|
const map = this._map;
|
||
|
|
if (!battle || !map) return false;
|
||
|
|
|
||
|
|
const session = battle.session;
|
||
|
|
const world = session.world;
|
||
|
|
if (!world.isAlive(victim)) return false;
|
||
|
|
|
||
|
|
const move = world.getComponent(victim, MovementComp);
|
||
|
|
if (!move) return false;
|
||
|
|
|
||
|
|
const center = session.grid.cellToWorld({ row, col });
|
||
|
|
move.worldX = center.x;
|
||
|
|
move.worldY = center.y;
|
||
|
|
move.speed = 0;
|
||
|
|
move.forcePresentSync = true;
|
||
|
|
|
||
|
|
const lockDur = FIRE_TRAP_DAMAGE_DELAY_SEC + FIRE_TRAP_KILL_DELAY_SEC + 0.05;
|
||
|
|
session.buffs.add(
|
||
|
|
victim,
|
||
|
|
FIRE_TRAP_IMMOBILIZE_BUFF_ID,
|
||
|
|
FIRE_TRAP_CASTER,
|
||
|
|
1,
|
||
|
|
{ duration: lockDur },
|
||
|
|
);
|
||
|
|
const buffs = world.getComponent(victim, BuffContainerComp);
|
||
|
|
if (!buffs?.buffs.some((b) => b.buffId === FIRE_TRAP_IMMOBILIZE_BUFF_ID)) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
const node = battle.getMonsterPresenter()?.getEntityNodeMap().get(victim);
|
||
|
|
if (node?.isValid) {
|
||
|
|
syncMonsterNode(session, map, victim, node);
|
||
|
|
}
|
||
|
|
|
||
|
|
move.presentLocked = true;
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 解除定身(幸存者飘血后恢复移动) */
|
||
|
|
private _unlockVictim(victim: EntityId): void {
|
||
|
|
const battle = this._battle;
|
||
|
|
if (!battle) return;
|
||
|
|
const world = battle.session.world;
|
||
|
|
if (!world.isAlive(victim)) return;
|
||
|
|
|
||
|
|
const move = world.getComponent(victim, MovementComp);
|
||
|
|
if (move) {
|
||
|
|
move.presentLocked = false;
|
||
|
|
move.forcePresentSync = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
const buffs = world.getComponent(victim, BuffContainerComp);
|
||
|
|
if (!buffs) return;
|
||
|
|
buffs.buffs = buffs.buffs.filter((b) => b.buffId !== FIRE_TRAP_IMMOBILIZE_BUFF_ID);
|
||
|
|
buffs.refreshDebuff4Flag();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** unbind 时解除仍被锁定的怪物 */
|
||
|
|
private _releaseAllVictims(): void {
|
||
|
|
const battle = this._battle;
|
||
|
|
if (!battle) return;
|
||
|
|
const world = battle.session.world;
|
||
|
|
world.forEachAlive((id) => {
|
||
|
|
const move = world.getComponent(id, MovementComp);
|
||
|
|
if (!move?.presentLocked) return;
|
||
|
|
const buffs = world.getComponent(id, BuffContainerComp);
|
||
|
|
if (!buffs?.buffs.some((b) => b.buffId === FIRE_TRAP_IMMOBILIZE_BUFF_ID)) return;
|
||
|
|
this._unlockVictim(id);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 「持续3」播完后回收陷阱 */
|
||
|
|
private _scheduleTrapRelease(trap: FireTrapEntry): void {
|
||
|
|
if (trap.releaseScheduled) return;
|
||
|
|
trap.releaseScheduled = true;
|
||
|
|
this._removeTrapEntry(trap);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 动画时长异常时的兜底回收 */
|
||
|
|
private _scheduleTrapReleaseFallback(trap: FireTrapEntry, skel: sp.Skeleton): void {
|
||
|
|
const duration = BattleSpinePlayback.getAnimDuration(skel, FIRE_TRAP_ANIM_BURST);
|
||
|
|
const delay = duration != null && duration > 0 ? duration : FIRE_TRAP_BURST_FALLBACK_SEC;
|
||
|
|
const scheduleGen = this._triggerGen;
|
||
|
|
schedulePresentation(delay, () => {
|
||
|
|
if (scheduleGen !== this._triggerGen) return;
|
||
|
|
this._scheduleTrapRelease(trap);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 飘血 + 可选延迟击杀(陷阱节点由「持续3」播完回收) */
|
||
|
|
private _applyTrapDamage(trap: FireTrapEntry, victim: EntityId, scheduleGen: number): void {
|
||
|
|
const battle = this._battle;
|
||
|
|
if (!battle || scheduleGen !== this._triggerGen) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const session = battle.session;
|
||
|
|
const world = session.world;
|
||
|
|
if (!world.isAlive(victim)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const tf = world.getComponent(victim, TransformComp);
|
||
|
|
if (!tf || tf.row !== trap.row || tf.col !== trap.col) {
|
||
|
|
this._unlockVictim(victim);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const hp = world.getComponent(victim, HealthComp);
|
||
|
|
if (!hp || hp.maxHp <= 0 || !(hp.hp > 0)) {
|
||
|
|
this._unlockVictim(victim);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const maxDmg = Math.max(1, Math.floor(trap.maxDamage));
|
||
|
|
/** 当前血量不超过伤害上限 → 按最大血量结算(即死) */
|
||
|
|
const amount = hp.hp <= maxDmg ? hp.maxHp : maxDmg;
|
||
|
|
const hpBefore = hp.hp;
|
||
|
|
|
||
|
|
session.damage.apply(FIRE_TRAP_CASTER, victim, amount, FIRE_TRAP_DAMAGE_TYPE, undefined, false);
|
||
|
|
session.events.flush();
|
||
|
|
session.events.emit(
|
||
|
|
BattleEventFactory.hitAfter(
|
||
|
|
FIRE_TRAP_CASTER,
|
||
|
|
victim,
|
||
|
|
amount,
|
||
|
|
false,
|
||
|
|
undefined,
|
||
|
|
FIRE_TRAP_DAMAGE_TYPE,
|
||
|
|
),
|
||
|
|
true,
|
||
|
|
);
|
||
|
|
|
||
|
|
if (hp.hp <= 0) {
|
||
|
|
schedulePresentation(FIRE_TRAP_KILL_DELAY_SEC, () => {
|
||
|
|
if (scheduleGen !== this._triggerGen || !this._battle) return;
|
||
|
|
const sess = this._battle.session;
|
||
|
|
const w = sess.world;
|
||
|
|
if (!w.isAlive(victim)) return;
|
||
|
|
const hpNow = w.getComponent(victim, HealthComp);
|
||
|
|
if (!hpNow || hpNow.hp > 0) return;
|
||
|
|
sess.lifecycle.kill(victim, FIRE_TRAP_CASTER, hpBefore);
|
||
|
|
this._unlockVictim(victim);
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
this._unlockVictim(victim);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 从列表移除并回收陷阱节点 */
|
||
|
|
private _removeTrapEntry(trap: FireTrapEntry): void {
|
||
|
|
const idx = this._traps.indexOf(trap);
|
||
|
|
if (idx >= 0) {
|
||
|
|
this._traps.splice(idx, 1);
|
||
|
|
}
|
||
|
|
this._releaseTrapNode(trap.node, trap.skel);
|
||
|
|
}
|
||
|
|
}
|