|
|
|
|
import { instantiate, Layout, Node, Sorting2D, tween, Tween, UIOpacity, UITransform, Vec3 } from 'cc';
|
|
|
|
|
import { GBundle } from '../../game/ConfigRes';
|
|
|
|
|
import {
|
|
|
|
|
BATTLE_EVENT_ALL,
|
|
|
|
|
BattleEventType,
|
|
|
|
|
BattlePhase,
|
|
|
|
|
type BattleEvent,
|
|
|
|
|
} from 'db://assets/mx/module/battle';
|
|
|
|
|
import type { ProjectBattleHandle } from '../runtime/BattleRuntime';
|
|
|
|
|
import type { MapManager } from '../map/legacy/MapManager';
|
|
|
|
|
import { resolveSpawnGoalWallName } from '../map/MapPathQueries';
|
|
|
|
|
|
|
|
|
|
/** 血条轨道总宽(与预制体「血量」节点一致) */
|
|
|
|
|
const BAR_TRACK_W = 60;
|
|
|
|
|
/** 预制体目录 */
|
|
|
|
|
const PREFAB_DIR = 'prefab/';
|
|
|
|
|
/** 营地血条预制体名 */
|
|
|
|
|
const PREFAB_NAME = '营地血条';
|
|
|
|
|
/** 血格容器节点名 */
|
|
|
|
|
const TRACK_NAME = '血量';
|
|
|
|
|
/** 血格模板节点名 */
|
|
|
|
|
const PIP_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;
|
|
|
|
|
|
|
|
|
|
/** 单营血条绑定 */
|
|
|
|
|
interface CampBar {
|
|
|
|
|
/** 玩家槽 ID */
|
|
|
|
|
playerId: number;
|
|
|
|
|
/** 血条根节点 */
|
|
|
|
|
root: Node;
|
|
|
|
|
/** 「血量」容器 */
|
|
|
|
|
track: Node;
|
|
|
|
|
/** 「长血格」模板 */
|
|
|
|
|
template: Node;
|
|
|
|
|
/** 当前展示中的血格子节点 */
|
|
|
|
|
pips: Node[];
|
|
|
|
|
/** 开场最大血量(用于格宽) */
|
|
|
|
|
maxHp: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 己方/敌方营地血条:格数=当前血,扣血渐隐回收 */
|
|
|
|
|
export class CampHpBarPresenter {
|
|
|
|
|
/** 当前战斗 */
|
|
|
|
|
private _battle: ProjectBattleHandle | null = null;
|
|
|
|
|
/** 地图根(血条挂在地图上,不随出怪口特效显隐) */
|
|
|
|
|
private _mapNode: Node | null = null;
|
|
|
|
|
/** 双方血条 */
|
|
|
|
|
private readonly _bars: CampBar[] = [];
|
|
|
|
|
/** 渐隐完成待回收队列(FIFO,与 tween 启动顺序一致) */
|
|
|
|
|
private readonly _fadeQueue: Node[] = [];
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 按本图挂血条:血条贴在该口怪物的终点下方。
|
|
|
|
|
* @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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 回收节点、停 tween、解事件 */
|
|
|
|
|
unbind(): void {
|
|
|
|
|
const battle = this._battle;
|
|
|
|
|
if (battle) {
|
|
|
|
|
battle.off(BATTLE_EVENT_ALL, this._onBattleEvent);
|
|
|
|
|
}
|
|
|
|
|
this._battle = null;
|
|
|
|
|
this._mapNode = null;
|
|
|
|
|
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 bar: CampBar = {
|
|
|
|
|
playerId,
|
|
|
|
|
root,
|
|
|
|
|
track,
|
|
|
|
|
template,
|
|
|
|
|
pips: [],
|
|
|
|
|
maxHp,
|
|
|
|
|
};
|
|
|
|
|
this._bars.push(bar);
|
|
|
|
|
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 * spawnUt.anchorY;
|
|
|
|
|
const localY = edgeLocalY - (barHalfH + BAR_EDGE_GAP);
|
|
|
|
|
const world = spawnUt.convertToWorldSpaceAR(new Vec3(0, localY, 0));
|
|
|
|
|
root.setWorldPosition(world);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 槽位当前最大血;被动/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);
|
|
|
|
|
if (bar.root?.isValid) {
|
|
|
|
|
Tween.stopAllByTarget(bar.root);
|
|
|
|
|
gg.res.putNode(bar.root);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|