diff --git a/Assembly-CSharp.csproj b/Assembly-CSharp.csproj
index 6817af5..cb36616 100644
--- a/Assembly-CSharp.csproj
+++ b/Assembly-CSharp.csproj
@@ -82,6 +82,7 @@
+
diff --git a/Assets/Scenes/GameMain.unity b/Assets/Scenes/GameMain.unity
index 5db965f..e2e13a9 100644
--- a/Assets/Scenes/GameMain.unity
+++ b/Assets/Scenes/GameMain.unity
@@ -1183,6 +1183,7 @@ GameObject:
- component: {fileID: 665708330}
- component: {fileID: 665708331}
- component: {fileID: 665708332}
+ - component: {fileID: 665708333}
m_Layer: 5
m_Name: TeamCardsRoot
m_TagString: Untagged
@@ -1253,6 +1254,22 @@ MonoBehaviour:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.ContentSizeFitter
m_HorizontalFit: 2
m_VerticalFit: 0
+--- !u!114 &665708333
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 665708329}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: b5135790bbd96064aab844c2ba2aaa32, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Assembly-CSharp::Northbound.TeamHealthUI
+ slot1: {fileID: 0}
+ slot2: {fileID: 0}
+ slot3: {fileID: 0}
+ slot4: {fileID: 0}
--- !u!1 &672563220
GameObject:
m_ObjectHideFlags: 0
diff --git a/Assets/Scripts/UI/TeamHealthUI.cs b/Assets/Scripts/UI/TeamHealthUI.cs
new file mode 100644
index 0000000..1649380
--- /dev/null
+++ b/Assets/Scripts/UI/TeamHealthUI.cs
@@ -0,0 +1,391 @@
+using System.Collections.Generic;
+using UnityEngine;
+using UnityEngine.UI;
+using TMPro;
+using Unity.Netcode;
+
+namespace Northbound
+{
+ ///
+ /// 팀원 체력 UI 관리
+ /// Slot 1: 로컬 플레이어 (본인)
+ /// Slot 2~4: 다른 플레이어 (플레이어 ID 순)
+ ///
+ public class TeamHealthUI : MonoBehaviour
+ {
+ [Header("Slot References (자동 할당됨)")]
+ [SerializeField] private TeamCardSlot slot1; // 로컬 플레이어 (본인)
+ [SerializeField] private TeamCardSlot slot2; // 다른 플레이어 1
+ [SerializeField] private TeamCardSlot slot3; // 다른 플레이어 2
+ [SerializeField] private TeamCardSlot slot4; // 다른 플레이어 3
+
+ private Dictionary _playerControllers = new Dictionary();
+ private Dictionary _playerSlotMapping = new Dictionary();
+ private ulong _localPlayerId = ulong.MaxValue;
+
+ private void Awake()
+ {
+ // 슬롯 초기화
+ InitializeSlots();
+ }
+
+ private void Start()
+ {
+ // 네트워크 이벤트 구독
+ if (NetworkManager.Singleton != null)
+ {
+ NetworkManager.Singleton.OnClientConnectedCallback += OnClientConnected;
+ NetworkManager.Singleton.OnClientDisconnectCallback += OnClientDisconnect;
+ }
+
+ // 이미 연결된 플레이어들 처리
+ RefreshAllPlayers();
+ }
+
+ private void OnDestroy()
+ {
+ // 이벤트 구독 해제
+ if (NetworkManager.Singleton != null)
+ {
+ NetworkManager.Singleton.OnClientConnectedCallback -= OnClientConnected;
+ NetworkManager.Singleton.OnClientDisconnectCallback -= OnClientDisconnect;
+ }
+
+ // 모든 플레이어 체력 변경 이벤트 구독 해제
+ foreach (var kvp in _playerControllers)
+ {
+ if (kvp.Value != null)
+ {
+ kvp.Value.OnOwnerChanged -= OnPlayerOwnerChanged;
+ }
+ }
+ }
+
+ private void InitializeSlots()
+ {
+ // 슬롯 자동 할당 - 이름으로 찾기
+ if (slot1 == null) slot1 = FindSlotByName("Slot_1");
+ if (slot2 == null) slot2 = FindSlotByName("Slot_2");
+ if (slot3 == null) slot3 = FindSlotByName("Slot_3");
+ if (slot4 == null) slot4 = FindSlotByName("Slot_4");
+
+ // 모든 슬롯 초기화 (비어있음)
+ slot1?.Clear();
+ slot2?.Clear();
+ slot3?.Clear();
+ slot4?.Clear();
+ }
+
+ ///
+ /// 이름으로 슬롯 찾기 (자식의 자식 프리팹까지 검색)
+ ///
+ private TeamCardSlot FindSlotByName(string slotName)
+ {
+ // 직접 자식에서 찾기
+ Transform slotTransform = transform.Find(slotName);
+ if (slotTransform == null) return null;
+
+ // 자식 프리팹에서 TeamCardSlot 찾기 또는 생성
+ TeamCardSlot slot = slotTransform.GetComponentInChildren(true);
+ if (slot == null)
+ {
+ // TeamCardSlot 컴포넌트를 자식 프리팹의 루트에 추가
+ // 프리팹 인스턴스의 첫 번째 자식이 실제 카드 프리팹
+ if (slotTransform.childCount > 0)
+ {
+ GameObject cardObj = slotTransform.GetChild(0).gameObject;
+ slot = cardObj.AddComponent();
+ }
+ else
+ {
+ // 자식이 없으면 슬롯 자체에 추가
+ slot = slotTransform.gameObject.AddComponent();
+ }
+ }
+
+ return slot;
+ }
+
+ private void OnClientConnected(ulong clientId)
+ {
+ // 약간의 딜레이 후 플레이어 컨트롤러가 생성될 때까지 대기
+ Invoke(nameof(RefreshAllPlayers), 0.5f);
+ }
+
+ private void OnClientDisconnect(ulong clientId)
+ {
+ // 플레이어 제거
+ RemovePlayer(clientId);
+ }
+
+ private void RefreshAllPlayers()
+ {
+ // 로컬 플레이어 ID 확인
+ if (NetworkManager.Singleton != null)
+ {
+ _localPlayerId = NetworkManager.Singleton.LocalClientId;
+ }
+
+ // 모든 NetworkPlayerController 찾기
+ var allPlayers = FindObjectsByType(FindObjectsSortMode.None);
+
+ // 기존 매핑 클리어
+ _playerControllers.Clear();
+
+ // 플레이어 등록
+ foreach (var player in allPlayers)
+ {
+ ulong playerId = player.OwnerPlayerId;
+ if (playerId != ulong.MaxValue)
+ {
+ _playerControllers[playerId] = player;
+ player.OnOwnerChanged -= OnPlayerOwnerChanged;
+ player.OnOwnerChanged += OnPlayerOwnerChanged;
+ }
+ }
+
+ // UI 업데이트
+ UpdateAllSlots();
+ }
+
+ private void OnPlayerOwnerChanged(ulong newOwnerId)
+ {
+ // 플레이어 소유자 변경 시 갱신
+ Invoke(nameof(RefreshAllPlayers), 0.1f);
+ }
+
+ private void UpdateAllSlots()
+ {
+ // 기존 매핑 클리어
+ _playerSlotMapping.Clear();
+ slot1?.Clear();
+ slot2?.Clear();
+ slot3?.Clear();
+ slot4?.Clear();
+
+ // 로컬 플레이어를 Slot 1에 배치
+ if (_playerControllers.TryGetValue(_localPlayerId, out var localPlayer))
+ {
+ slot1?.SetPlayer(localPlayer, true);
+ _playerSlotMapping[_localPlayerId] = slot1;
+ }
+
+ // 다른 플레이어들을 ID 순으로 Slot 2~4에 배치
+ var otherPlayers = new List>();
+ foreach (var kvp in _playerControllers)
+ {
+ if (kvp.Key != _localPlayerId)
+ {
+ otherPlayers.Add(kvp);
+ }
+ }
+
+ // ID 순으로 정렬
+ otherPlayers.Sort((a, b) => a.Key.CompareTo(b.Key));
+
+ // 슬롯에 배치
+ var slots = new[] { slot2, slot3, slot4 };
+ for (int i = 0; i < otherPlayers.Count && i < slots.Length; i++)
+ {
+ var player = otherPlayers[i];
+ slots[i]?.SetPlayer(player.Value, false);
+ _playerSlotMapping[player.Key] = slots[i];
+ }
+ }
+
+ private void RemovePlayer(ulong playerId)
+ {
+ if (_playerControllers.TryGetValue(playerId, out var player))
+ {
+ player.OnOwnerChanged -= OnPlayerOwnerChanged;
+ _playerControllers.Remove(playerId);
+ }
+
+ if (_playerSlotMapping.TryGetValue(playerId, out var slot))
+ {
+ slot?.Clear();
+ _playerSlotMapping.Remove(playerId);
+ }
+
+ // 슬롯 재배치
+ UpdateAllSlots();
+ }
+ }
+
+ ///
+ /// 개별 팀 카드 슬롯
+ /// TeamCard_Large 또는 TeamCard_Small 프리팹에 붙여서 사용
+ ///
+ public class TeamCardSlot : MonoBehaviour
+ {
+ [Header("UI References (자동 할당됨)")]
+ [SerializeField] private TextMeshProUGUI _playerNameText;
+ [SerializeField] private Image _hpBarFill;
+ [SerializeField] private TextMeshProUGUI _hpText;
+
+ private NetworkPlayerController _player;
+ private bool _isLocalPlayer;
+ private int _lastHealth = -1;
+ private int _lastMaxHealth = -1;
+
+ private void Awake()
+ {
+ // 자동으로 UI 요소 찾기
+ FindUIElements();
+ }
+
+ private void FindUIElements()
+ {
+ // PlayerNameText 찾기 - 이름으로 정확히 찾기
+ if (_playerNameText == null)
+ {
+ Transform nameText = FindDeepChild(transform, "PlayerNameText");
+ if (nameText != null)
+ {
+ _playerNameText = nameText.GetComponent();
+ }
+ }
+
+ // HPBarFill 찾기
+ if (_hpBarFill == null)
+ {
+ Transform hpFill = FindDeepChild(transform, "HPBarFill");
+ if (hpFill != null)
+ {
+ _hpBarFill = hpFill.GetComponent();
+ }
+ }
+
+ // HPText 찾기
+ if (_hpText == null)
+ {
+ Transform hpText = FindDeepChild(transform, "HPText");
+ if (hpText != null)
+ {
+ _hpText = hpText.GetComponent();
+ }
+ }
+ }
+
+ ///
+ /// 깊이 우선 탐색으로 자식 찾기
+ ///
+ private Transform FindDeepChild(Transform parent, string name)
+ {
+ // 직접 자식에서 찾기
+ Transform found = parent.Find(name);
+ if (found != null) return found;
+
+ // 재귀적으로 자식의 자식에서 찾기
+ for (int i = 0; i < parent.childCount; i++)
+ {
+ found = FindDeepChild(parent.GetChild(i), name);
+ if (found != null) return found;
+ }
+ return null;
+ }
+
+ public void SetPlayer(NetworkPlayerController player, bool isLocalPlayer)
+ {
+ _player = player;
+ _isLocalPlayer = isLocalPlayer;
+ _lastHealth = -1; // 강제 업데이트를 위해 리셋
+ _lastMaxHealth = -1;
+
+ if (player == null)
+ {
+ Clear();
+ return;
+ }
+
+ // UI 요소가 없으면 다시 찾기
+ FindUIElements();
+
+ // 플레이어 이름 설정
+ if (_playerNameText != null)
+ {
+ string playerName = $"Player {player.OwnerPlayerId + 1}";
+ if (isLocalPlayer)
+ {
+ playerName += " (YOU)";
+ }
+ _playerNameText.text = playerName;
+ }
+
+ // 초기 체력 업데이트
+ UpdateHealth();
+
+ // 활성화
+ gameObject.SetActive(true);
+ }
+
+ public void Clear()
+ {
+ _player = null;
+ _lastHealth = -1;
+ _lastMaxHealth = -1;
+
+ if (_playerNameText != null)
+ {
+ _playerNameText.text = "Waiting...";
+ }
+
+ if (_hpBarFill != null)
+ {
+ _hpBarFill.fillAmount = 0f;
+ }
+
+ if (_hpText != null)
+ {
+ _hpText.text = "--- / ---";
+ }
+ }
+
+ private void LateUpdate()
+ {
+ // 체력 업데이트 (변경 시에만)
+ if (_player != null)
+ {
+ int currentHp = _player.GetCurrentHealth();
+ int maxHp = _player.GetMaxHealth();
+
+ // 체력이나 최대 체력이 변경된 경우에만 UI 업데이트
+ if (currentHp != _lastHealth || maxHp != _lastMaxHealth)
+ {
+ UpdateHealth();
+ _lastHealth = currentHp;
+ _lastMaxHealth = maxHp;
+ }
+ }
+ }
+
+ private void UpdateHealth()
+ {
+ if (_player == null) return;
+
+ int currentHp = _player.GetCurrentHealth();
+ int maxHp = _player.GetMaxHealth();
+ float hpPercent = maxHp > 0 ? (float)currentHp / maxHp : 0f;
+
+ if (_hpBarFill != null)
+ {
+ _hpBarFill.fillAmount = hpPercent;
+ }
+
+ if (_hpText != null)
+ {
+ _hpText.text = $"{currentHp} / {maxHp}";
+ }
+ }
+
+ ///
+ /// 에디터에서 UI 요소 자동 할당
+ ///
+ [ContextMenu("Auto Find UI Elements")]
+ private void AutoFindUIElements()
+ {
+ FindUIElements();
+ Debug.Log($"[TeamCardSlot] UI Elements Found - Name: {_playerNameText != null}, HPFill: {_hpBarFill != null}, HPText: {_hpText != null}");
+ }
+ }
+}
diff --git a/Assets/Scripts/UI/TeamHealthUI.cs.meta b/Assets/Scripts/UI/TeamHealthUI.cs.meta
new file mode 100644
index 0000000..fd2cc78
--- /dev/null
+++ b/Assets/Scripts/UI/TeamHealthUI.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: b5135790bbd96064aab844c2ba2aaa32
\ No newline at end of file