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]); } } /** 气血丸等改 wallMaxHp 后按槽位重建格宽/格数 */ syncFromSlots(): void { this._rebuildAllFromSlots(); } /** 回收节点、停 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); } } }