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/view/CampHpBarPresenter.ts

546 lines
18 KiB

import { instantiate, Layout, Node, Sorting2D, sp, tween, Tween, UIOpacity, UITransform, Vec3 } from 'cc';
import type { EntityId } from 'db://assets/mx/module/ecs';
import {
BATTLE_EVENT_ALL,
BattleEventType,
BattlePhase,
BattleTeamId,
MovementComp,
TeamComp,
isBajiaoBlowRetreating,
isYanSoulClaimed,
type BattleEvent,
} from 'db://assets/mx/module/battle';
import { GBundle } from '../../game/ConfigRes';
import { MonsterType } from '../../manager/MonsterDataManager';
import { isMechanismUnit } from '../mechanism/MechanismServices';
import type { MapManager } from '../map/legacy/MapManager';
import { resolveSpawnGoalWallName } from '../map/MapPathQueries';
import { BattleSpinePlayback } from '../presentation/BattleSpinePlayback';
import type { ProjectBattleHandle } from '../runtime/BattleRuntime';
/** 血条轨道总宽(与预制体「血量」节点一致) */
const BAR_TRACK_W = 60;
/** 预制体目录 */
const PREFAB_DIR = 'prefab/';
/** 营地血条预制体名 */
const PREFAB_NAME = '营地血条';
/** 血格容器节点名 */
const TRACK_NAME = '血量';
/** 血格模板节点名 */
const PIP_NAME = '长血格';
/** π 动效节点名 */
const PAI_NAME = '保护派派';
/** 出怪口节点名前缀(spawnPoint_N) */
const SPAWN_PREFIX = 'spawnPoint_';
/** 血条贴营地外观的外扩(像素);负值表示向营地内收,避开贴图透明边 */
const BAR_EDGE_GAP = -20;
/** 血条 Sorting2D 层(与攻击点同层,order 更高) */
const BAR_SORT_LAYER = 3;
/** 血条根 sortingOrder;子节点逐级 +1,避免被血量底盖住 */
const BAR_SORT_ORDER = 20;
/** 扣血格渐隐秒 */
const PIP_FADE_SEC = 0.2;
/** π 默认待机 */
const PAI_ANIM_IDLE = '待机';
/** π 怪物靠近时 */
const PAI_ANIM_SCARE = '害怕';
/** 进攻怪剩余路径格数 ≤ 此值视为靠近(与危机提示一致) */
const SCARE_PATH_CELLS = 3;
/** 靠近检测降频(帧) */
const SCARE_SCAN_PERIOD = 8;
/** 单营血条绑定 */
interface CampBar {
/** 玩家槽 ID(被进攻的防守方) */
playerId: number;
/** 血条根节点 */
root: Node;
/** 「血量」容器 */
track: Node;
/** 「长血格」模板 */
template: Node;
/** 当前展示中的血格子节点 */
pips: Node[];
/** 开场最大血量(用于格宽) */
maxHp: number;
/** 保护派派 Spine */
skeleton: sp.Skeleton | null;
/** 是否正在播害怕 */
scared: boolean;
}
/** 己方/敌方营地血条:格数=当前血,扣血渐隐回收;π 默认待机、怪靠近播害怕 */
export class CampHpBarPresenter {
/** 当前战斗 */
private _battle: ProjectBattleHandle | null = null;
/** 地图根(血条挂在地图上,不随出怪口特效显隐) */
private _mapNode: Node | null = null;
/** 双方血条 */
private readonly _bars: CampBar[] = [];
/** 渐隐完成待回收队列(FIFO,与 tween 启动顺序一致) */
private readonly _fadeQueue: Node[] = [];
/** 靠近扫描帧计数 */
private _scareScanAcc = 0;
/**
* 按本图挂血条:贴在该口怪物终点上方(预制体血条在上、π 在下,双方同一套)。
* @param battle 战斗句柄
* @param map 地图
*/
bind(battle: ProjectBattleHandle, map: MapManager): void {
this.unbind();
this._battle = battle;
this._mapNode = map.node;
battle.on(BATTLE_EVENT_ALL, this._onBattleEvent);
const spawns = map.spawnPointParent?.children;
if (!spawns || spawns.length === 0) {
console.log('[CampHpBar] 地图无出怪口,无法挂血条');
return;
}
for (let i = 0; i < spawns.length; i++) {
const spawn = spawns[i];
const anchor = this._placeAnchor(map, spawn);
if (!anchor) continue;
this._mountBar(spawn, anchor);
}
}
/** 帧刷新:怪物靠近时切害怕,否则待机 */
tick(): void {
const battle = this._battle;
if (!battle || battle.session.ended || this._bars.length <= 0) return;
this._scareScanAcc++;
if (this._scareScanAcc % SCARE_SCAN_PERIOD !== 1) return;
for (let i = 0; i < this._bars.length; i++) {
this._refreshScare(battle, this._bars[i]);
}
}
/** 回收节点、停 tween、解事件 */
unbind(): void {
const battle = this._battle;
if (battle) {
battle.off(BATTLE_EVENT_ALL, this._onBattleEvent);
}
this._battle = null;
this._mapNode = null;
this._scareScanAcc = 0;
this._clearFades();
for (let i = 0; i < this._bars.length; i++) {
this._recycleBar(this._bars[i]);
}
this._bars.length = 0;
}
/** 扣血渐隐或复活后重建 */
private readonly _onBattleEvent = (e: BattleEvent): void => {
if (e.type === BattleEventType.WallDamaged) {
this._fadeToRemain(e.playerId, e.remain);
return;
}
if (e.type !== BattleEventType.PhaseChanged) return;
if (e.phase === BattlePhase.COMBAT && e.prev === BattlePhase.WAIT_REVIVE) {
this._rebuildAllFromSlots();
}
};
/**
* 血条挂到该出怪口的终点:spawnPoint_1 → wallTarget2,spawnPoint_2 → wallTarget1。
* @param map 地图
* @param spawnNode 出怪口
*/
private _placeAnchor(map: MapManager, spawnNode: Node): Node | null {
const port = this._portFromSpawnName(spawnNode.name);
if (port < 1) return null;
const name = resolveSpawnGoalWallName(port);
if (!name) return null;
const wall = map.mapTargetParent?.getChildByName(name);
if (!wall?.isValid) {
console.warn('[CampHpBar] 缺少攻击点,血条无法挂上', name);
return null;
}
return wall;
}
/**
* @param spawnNode 出怪口(决定玩家槽)
* @param anchor 血条位置节点
*/
private _mountBar(spawnNode: Node, anchor: Node): void {
if (!spawnNode?.isValid || !anchor?.isValid) {
console.log('[CampHpBar] 出怪口节点无效');
return;
}
const battle = this._battle;
const mapNode = this._mapNode;
if (!battle || !mapNode?.isValid) return;
const port = this._portFromSpawnName(spawnNode.name);
if (port < 1) {
console.log('[CampHpBar] 出怪口名非法', spawnNode.name);
return;
}
const playerId = port - 1;
const slot = battle.session.players[playerId];
if (!slot) {
console.log('[CampHpBar] 无玩家槽', playerId, spawnNode.name);
return;
}
const maxHp = Math.floor(slot.wallMaxHp);
if (!(maxHp > 0)) {
console.log('[CampHpBar] 营地最大血量非法', playerId, slot.wallMaxHp);
return;
}
const root = gg.res.getNode(PREFAB_DIR, PREFAB_NAME, GBundle.BattleGame);
if (!root?.isValid) {
console.log('[CampHpBar] 预制体加载失败', PREFAB_NAME);
return;
}
const track = root.getChildByName(TRACK_NAME);
const template = track?.getChildByName(PIP_NAME);
if (!track || !template) {
console.log('[CampHpBar] 预制体缺「血量」或「长血格」');
gg.res.putNode(root);
return;
}
root.parent = mapNode;
this._syncLayer(root, anchor.layer);
this._placeAtCamp(root, anchor);
const skeleton = this._resolvePaiSkeleton(root);
const bar: CampBar = {
playerId,
root,
track,
template,
pips: [],
maxHp,
skeleton,
scared: false,
};
this._bars.push(bar);
this._playPai(bar, false);
this._fillPips(bar, slot.wallHp);
}
/**
* @param name spawnPoint_N
*/
private _portFromSpawnName(name: string): number {
if (name.indexOf(SPAWN_PREFIX) !== 0) return -1;
const n = Number(name.substring(SPAWN_PREFIX.length));
if (!Number.isInteger(n) || n < 1) return -1;
return n;
}
/**
* @param node 节点
* @param layer 与出怪口一致
*/
private _syncLayer(node: Node, layer: number): void {
node.layer = layer;
const children = node.children;
for (let i = 0; i < children.length; i++) {
this._syncLayer(children[i], layer);
}
}
/**
* 子节点必须自带 Sorting2D,否则绿格会被血量底/地图盖住。
* @param node 节点
* @param order 本节点 order,子节点 +1
*/
private _applySorting(node: Node, order: number): void {
const s = node.getComponent(Sorting2D) ?? node.addComponent(Sorting2D);
s.sortingLayer = BAR_SORT_LAYER;
s.sortingOrder = order;
const children = node.children;
for (let i = 0; i < children.length; i++) {
this._applySorting(children[i], order + 1);
}
}
/**
* 双方都贴攻击点上方:血条在上、π 在下,落在中间墙两侧格子上。
* @param root 血条根
* @param anchor 位置节点(wallTargetN)
*/
private _placeAtCamp(root: Node, anchor: Node): void {
const spawnUt = anchor.getComponent(UITransform);
if (!spawnUt) {
console.log('[CampHpBar] 血条挂点无 UITransform', anchor.name);
root.setWorldPosition(anchor.worldPosition);
return;
}
const barUt = root.getComponent(UITransform);
if (!barUt) {
console.log('[CampHpBar] 血条无 UITransform');
}
const barHalfH = barUt ? barUt.height * 0.5 : 0;
const edgeLocalY = spawnUt.height * (1 - spawnUt.anchorY);
const localY = edgeLocalY + (barHalfH + BAR_EDGE_GAP);
const world = spawnUt.convertToWorldSpaceAR(new Vec3(0, localY, 0));
root.setWorldPosition(world);
}
/**
* @param root 血条根
*/
private _resolvePaiSkeleton(root: Node): sp.Skeleton | null {
const pai = root.getChildByName(PAI_NAME);
if (!pai?.isValid) {
console.warn('[CampHpBar] 预制体缺「保护派派」');
return null;
}
const skel = pai.getComponent(sp.Skeleton);
if (!skel) {
console.warn('[CampHpBar] 保护派派无 Skeleton');
return null;
}
return skel;
}
/**
* @param battle 战斗
* @param bar 血条
*/
private _refreshScare(battle: ProjectBattleHandle, bar: CampBar): void {
if (!bar.skeleton?.isValid) return;
const near = this._hasNearAttacker(battle, bar.playerId);
if (near === bar.scared) return;
this._playPai(bar, near);
}
/**
* 进攻该防守方的怪是否已进入靠近距离。
* @param battle 战斗
* @param defenderPlayerId 防守方
*/
private _hasNearAttacker(battle: ProjectBattleHandle, defenderPlayerId: number): boolean {
const session = battle.session;
let found = false;
session.world.forEachAlive((entityId) => {
if (found) return;
if (!this._isScareCandidate(battle, defenderPlayerId, entityId)) return;
const move = session.world.getComponent(entityId, MovementComp);
if (!move) return;
if (move.reachedGoal) {
found = true;
return;
}
const remainCells = move.path.length - move.pathIndex;
if (remainCells <= SCARE_PATH_CELLS) found = true;
});
return found;
}
/**
* @param battle 战斗
* @param defenderPlayerId 防守方
* @param entityId 实体
*/
private _isScareCandidate(battle: ProjectBattleHandle, defenderPlayerId: number, entityId: EntityId): boolean {
const session = battle.session;
const world = session.world;
if (!world.isAlive(entityId)) return false;
if (isMechanismUnit(session, entityId)) return false;
const team = world.getComponent(entityId, TeamComp);
if (!team || !BattleTeamId.isEnemyTeam(team.team)) return false;
if (Math.max(0, Math.floor(Number(team.ownerId) || 0)) !== defenderPlayerId) return false;
const row = battle.resolveMonsterRow(entityId);
if (!row) return false;
const type = Math.floor(Number(row.type));
if (type !== MonsterType.SmallMonster && type !== MonsterType.Boss && type !== MonsterType.SummonMonster) {
return false;
}
const move = world.getComponent(entityId, MovementComp);
if (!move || move.spawnProtected || move.presentLocked || move.imprisoned) return false;
if (isYanSoulClaimed(move) || isBajiaoBlowRetreating(move)) return false;
return true;
}
/**
* @param bar 血条
* @param scare 是否害怕
*/
private _playPai(bar: CampBar, scare: boolean): void {
const skel = bar.skeleton;
if (!skel?.isValid) return;
bar.scared = scare;
const name = scare ? PAI_ANIM_SCARE : PAI_ANIM_IDLE;
BattleSpinePlayback.play(skel, name, { loop: true, optional: true });
}
/**
* 槽位当前最大血;被动/AI 加血后必须重读,不能用挂条时的快照。
* @param bar 血条
*/
private _resolveMaxHp(bar: CampBar): number {
const slot = this._battle?.session.players[bar.playerId];
const live = Math.floor(slot?.wallMaxHp ?? 0);
if (live > 0) return live;
return bar.maxHp > 0 ? bar.maxHp : 1;
}
/**
* @param bar 血条
* @param hp 当前血量(格数)
*/
private _fillPips(bar: CampBar, hp: number): void {
const maxHp = this._resolveMaxHp(bar);
bar.maxHp = maxHp;
const count = Math.max(0, Math.floor(hp));
if (count <= 0) {
console.log('[CampHpBar] 当前血量为 0,血条为空', bar.playerId, hp);
}
const pipW = BAR_TRACK_W / maxHp;
bar.template.active = true;
for (let i = 0; i < count; i++) {
const pip = instantiate(bar.template);
pip.active = true;
pip.layer = bar.root.layer;
pip.parent = bar.track;
const ut = pip.getComponent(UITransform);
if (ut) ut.setContentSize(pipW, ut.contentSize.height);
let op = pip.getComponent(UIOpacity);
if (!op) op = pip.addComponent(UIOpacity);
op.opacity = 255;
bar.pips.push(pip);
}
bar.template.active = false;
bar.track.getComponent(Layout)?.updateLayout();
this._applySorting(bar.root, BAR_SORT_ORDER);
}
/**
* @param playerId 玩家槽
* @param remain 剩余血量
*/
private _fadeToRemain(playerId: number, remain: number): void {
const bar = this._findBar(playerId);
if (!bar) return;
const target = Math.max(0, Math.floor(remain));
const maxHp = this._resolveMaxHp(bar);
// 最大血变了(被动加血等):整条按新格宽重建,避免旧宽格拉长
if (target > bar.pips.length || maxHp !== bar.maxHp) {
this._rebuildBar(bar, target);
return;
}
while (bar.pips.length > target) {
const pip = bar.pips.pop();
if (!pip?.isValid) continue;
this._fadePip(pip);
}
}
/** 复活满血后按槽位重建格 */
private _rebuildAllFromSlots(): void {
const battle = this._battle;
if (!battle) return;
for (let i = 0; i < this._bars.length; i++) {
const bar = this._bars[i];
const slot = battle.session.players[bar.playerId];
if (!slot) {
console.log('[CampHpBar] 重建时无玩家槽', bar.playerId);
continue;
}
this._rebuildBar(bar, slot.wallHp);
}
}
/**
* @param bar 血条
* @param hp 目标格数
*/
private _rebuildBar(bar: CampBar, hp: number): void {
this._destroyPips(bar);
this._fillPips(bar, hp);
}
/**
* @param pip 待渐隐回收的血格
*/
private _fadePip(pip: Node): void {
Tween.stopAllByTarget(pip);
let op = pip.getComponent(UIOpacity);
if (!op) op = pip.addComponent(UIOpacity);
this._fadeQueue.push(pip);
tween(op).to(PIP_FADE_SEC, { opacity: 0 }).call(this._onPipFadeDone).start();
}
/** 渐隐结束回收血格 */
private readonly _onPipFadeDone = (): void => {
const pip = this._fadeQueue.shift();
if (!pip) return;
this._destroyPip(pip);
};
/**
* @param playerId 玩家槽
*/
private _findBar(playerId: number): CampBar | null {
for (let i = 0; i < this._bars.length; i++) {
if (this._bars[i].playerId === playerId) return this._bars[i];
}
return null;
}
/**
* @param bar 血条
*/
private _destroyPips(bar: CampBar): void {
for (let i = 0; i < bar.pips.length; i++) {
this._destroyPip(bar.pips[i]);
}
bar.pips.length = 0;
this._purgeFadeQueue(bar.track);
}
/**
* @param pip 血格节点
*/
private _destroyPip(pip: Node): void {
if (!pip?.isValid) return;
Tween.stopAllByTarget(pip);
const op = pip.getComponent(UIOpacity);
if (op) Tween.stopAllByTarget(op);
pip.removeFromParent();
pip.destroy();
}
/**
* 清掉仍挂在该容器下的渐隐队列项。
* @param track 血量容器
*/
private _purgeFadeQueue(track: Node): void {
for (let i = this._fadeQueue.length - 1; i >= 0; i--) {
const n = this._fadeQueue[i];
if (n?.isValid && n.parent === track) {
this._destroyPip(n);
this._fadeQueue.splice(i, 1);
}
}
}
/** 停掉所有渐隐 tween 并销毁节点 */
private _clearFades(): void {
for (let i = 0; i < this._fadeQueue.length; i++) {
this._destroyPip(this._fadeQueue[i]);
}
this._fadeQueue.length = 0;
}
/**
* @param bar 血条
*/
private _recycleBar(bar: CampBar): void {
this._destroyPips(bar);
const skel = bar.skeleton;
if (skel?.isValid) BattleSpinePlayback.clearPendingComplete(skel);
bar.skeleton = null;
bar.scared = false;
if (bar.root?.isValid) {
Tween.stopAllByTarget(bar.root);
gg.res.putNode(bar.root);
}
}
}