diff --git a/AGENTS.md b/AGENTS.md index 03a6851b..63ec28a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -334,7 +334,8 @@ public class NetworkedComponent : NetworkBehaviour - For Unity work, prefer Unity MCP for active scene inspection, runtime verification, prefab checks, and console review when it is available in the session. - Never edit code, scenes, prefabs, components, or Unity asset settings while the Unity Editor is in play mode. Stop play mode first, then edit. -- After Unity-related edits, refresh or compile as needed and check the Unity console before proceeding. +- **CRITICAL**: After any code change (edit, create, delete), always perform a force refresh with compile request and wait for ready before entering play mode. Failing to do so causes mid-play compilation which can leave network ports occupied on the next run. Use `refresh_unity(mode="force", compile="request", wait_for_ready=true)`. +- After Unity-related edits, check the Unity console for errors before proceeding. - For networked play tests, prefer a temporary non-conflicting test port when needed and restore the default port after validation. - The user has a strong project preference that play mode must be stopped before edits because network ports can remain occupied otherwise. - Commit messages should follow the recent project history style: use a type prefix such as `feat:`, `fix:`, or `chore:` and write the subject in Korean so the gameplay/UI/system change is clear from the log alone. diff --git a/Assets/_Game/Data/Skills/Data_Skill_Player_부활.asset b/Assets/_Game/Data/Skills/Data_Skill_Player_부활.asset new file mode 100644 index 00000000..77f52d8a --- /dev/null +++ b/Assets/_Game/Data/Skills/Data_Skill_Player_부활.asset @@ -0,0 +1,37 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 94f0a76cebcac2f4fb5daf1b675fd79f, type: 3} + m_Name: "Data_Skill_Player_\uBD80\uD65C" + m_EditorClassIdentifier: Colosseum.Game::Colosseum.Skills.SkillData + skillName: "\uBD80\uD65C" + description: "\uBE48\uC0AC \uC0C1\uD0DC\uC778 \uC544\uAD70\uC744 \uBD80\uD65C\uC2DC\uD0B5\uB2C8\uB2E4." + icon: {fileID: 0} + skillRole: 1 + activationType: 1 + baseTypes: 0 + skillClip: {fileID: -8689311932429934276, guid: 4450ee0d92144ade9f63dd601432d3bf, type: 3} + endClip: {fileID: 0} + animationSpeed: 1 + useRootMotion: 0 + ignoreRootMotionY: 1 + jumpToTarget: 0 + blockMovementWhileCasting: 1 + blockJumpWhileCasting: 1 + blockOtherSkillsWhileCasting: 1 + cooldown: 10 + manaCost: 0 + maxGemSlotCount: 2 + castStartEffects: [] + effects: + - {fileID: 11400000, guid: 4242aba9acf45a545a3aa0201125a3ae, type: 2} + targetTeam: 1 + targetType: 0 diff --git a/Assets/_Game/Data/Skills/Data_Skill_Player_부활.asset.meta b/Assets/_Game/Data/Skills/Data_Skill_Player_부활.asset.meta new file mode 100644 index 00000000..c995bc10 --- /dev/null +++ b/Assets/_Game/Data/Skills/Data_Skill_Player_부활.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c677bf79cbc6af04bae23d11670f82fe +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Player_부활.asset b/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Player_부활.asset new file mode 100644 index 00000000..4da5510d --- /dev/null +++ b/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Player_부활.asset @@ -0,0 +1,27 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 848cbb76281c68842a4d00329110b769, type: 3} + m_Name: "Data_SkillEffect_Player_\uBD80\uD65C" + m_EditorClassIdentifier: Colosseum.Game::Colosseum.Skills.Effects.ReviveEffect + targetType: 1 + targetTeam: 1 + areaCenter: 0 + areaShape: 0 + targetLayers: + serializedVersion: 2 + m_Bits: 4294967295 + includeCasterInArea: 0 + areaRadius: 3 + fanOriginDistance: 1 + fanRadius: 3 + fanHalfAngle: 45 + healthPercent: 0.3 diff --git a/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Player_부활.asset.meta b/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Player_부활.asset.meta new file mode 100644 index 00000000..d645a9b8 --- /dev/null +++ b/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Player_부활.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4242aba9acf45a545a3aa0201125a3ae +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Core/GameManager.cs b/Assets/_Game/Scripts/Core/GameManager.cs index 04b5008c..0dd81cd4 100644 --- a/Assets/_Game/Scripts/Core/GameManager.cs +++ b/Assets/_Game/Scripts/Core/GameManager.cs @@ -356,6 +356,7 @@ namespace Colosseum #region Player Death Tracking private List alivePlayers = new List(); + private HashSet subscribedPlayers = new HashSet(); private IEnumerator WaitForPlayersAndSubscribe() { @@ -373,16 +374,27 @@ namespace Colosseum var players = FindObjectsByType(FindObjectsSortMode.None); foreach (var player in players) { - player.OnDeath += HandlePlayerDeath; - if (!player.IsDead) - { - alivePlayers.Add(player); - } + SubscribeSinglePlayer(player); } if (debugMode) { - Debug.Log($"[GameManager] Subscribed to {players.Length} players, {alivePlayers.Count} alive"); + Debug.Log($"[GameManager] Subscribed to {subscribedPlayers.Count} players, {alivePlayers.Count} alive"); + } + } + + private void SubscribeSinglePlayer(PlayerNetworkController player) + { + if (player == null || subscribedPlayers.Contains(player)) + return; + + player.OnDeath += HandlePlayerDeath; + player.OnRevived += HandlePlayerRevived; + subscribedPlayers.Add(player); + + if (!player.IsDead) + { + alivePlayers.Add(player); } } @@ -392,8 +404,25 @@ namespace Colosseum foreach (var player in players) { player.OnDeath -= HandlePlayerDeath; + player.OnRevived -= HandlePlayerRevived; } alivePlayers.Clear(); + subscribedPlayers.Clear(); + } + + /// + /// MPP 등으로 나중에 스폰된 플레이어를 동적으로 감지하여 구독합니다. + /// + private void Update() + { + if (!IsServer || currentState.Value != GameState.Playing) + return; + + var players = FindObjectsByType(FindObjectsSortMode.None); + for (int i = 0; i < players.Length; i++) + { + SubscribeSinglePlayer(players[i]); + } } private void HandlePlayerDeath(PlayerNetworkController player) @@ -412,6 +441,19 @@ namespace Colosseum } } + private void HandlePlayerRevived(PlayerNetworkController player) + { + if (alivePlayers.Contains(player)) + return; + + alivePlayers.Add(player); + + if (debugMode) + { + Debug.Log($"[GameManager] Player revived. Alive: {alivePlayers.Count}"); + } + } + #endregion #region Boss Death Tracking diff --git a/Assets/_Game/Scripts/Editor/PlayerSkillDebugMenu.cs b/Assets/_Game/Scripts/Editor/PlayerSkillDebugMenu.cs index 72f65aaf..356168cb 100644 --- a/Assets/_Game/Scripts/Editor/PlayerSkillDebugMenu.cs +++ b/Assets/_Game/Scripts/Editor/PlayerSkillDebugMenu.cs @@ -130,6 +130,12 @@ namespace Colosseum.Editor CastOwnedPlayerSkillAsServer(1, 5); } + [MenuItem("Tools/Colosseum/Debug/Cast Client1 Skill 5")] + private static void CastClient1Skill5() + { + CastOwnedPlayerSkillAsServer(1, 6); + } + [MenuItem("Tools/Colosseum/Debug/Cast Local Heal")] private static void CastLocalHeal() { @@ -167,6 +173,129 @@ namespace Colosseum.Editor localNetworkController.TakeDamageRpc(30f); } + [MenuItem("Tools/Colosseum/Debug/Kill Local Player")] + private static void KillLocalPlayer() + { + if (!EditorApplication.isPlaying) + { + Debug.LogWarning("[Debug] 플레이 모드에서만 사용할 수 있습니다."); + return; + } + + PlayerNetworkController localNetworkController = FindLocalNetworkController(); + if (localNetworkController == null) + { + Debug.LogWarning("[Debug] 로컬 PlayerNetworkController를 찾지 못했습니다."); + return; + } + + localNetworkController.TakeDamageRpc(localNetworkController.Health + 999f); + Debug.Log($"[Debug] 로컬 플레이어 즉사 | HP={localNetworkController.Health:F1}"); + } + + [MenuItem("Tools/Colosseum/Debug/Revive Local Player")] + private static void ReviveLocalPlayer() + { + if (!EditorApplication.isPlaying) + { + Debug.LogWarning("[Debug] 플레이 모드에서만 사용할 수 있습니다."); + return; + } + + PlayerNetworkController localNetworkController = FindLocalNetworkController(); + if (localNetworkController == null) + { + Debug.LogWarning("[Debug] 로컬 PlayerNetworkController를 찾지 못했습니다."); + return; + } + + if (!localNetworkController.IsDead) + { + Debug.LogWarning("[Debug] 로컬 플레이어가 사망 상태가 아닙니다."); + return; + } + + localNetworkController.Revive(0.3f); + Debug.Log($"[Debug] 로컬 플레이어 부활 | HP={localNetworkController.Health:F1}"); + } + + [MenuItem("Tools/Colosseum/Debug/Respawn Local Player")] + private static void RespawnLocalPlayer() + { + if (!EditorApplication.isPlaying) + { + Debug.LogWarning("[Debug] 플레이 모드에서만 사용할 수 있습니다."); + return; + } + + PlayerNetworkController localNetworkController = FindLocalNetworkController(); + if (localNetworkController == null) + { + Debug.LogWarning("[Debug] 로컬 PlayerNetworkController를 찾지 못했습니다."); + return; + } + + localNetworkController.Respawn(); + Debug.Log($"[Debug] 로컬 플레이어 리스폰 | HP={localNetworkController.Health:F1}"); + } + + [MenuItem("Tools/Colosseum/Debug/Spawn Players")] + private static void SpawnPlayers() + { + if (!EditorApplication.isPlaying) + { + Debug.LogWarning("[Debug] 플레이 모드에서만 사용할 수 있습니다."); + return; + } + + var networkManager = Unity.Netcode.NetworkManager.Singleton; + if (networkManager == null || !networkManager.IsServer) + { + Debug.LogWarning("[Debug] NetworkManager가 없거나 서버가 아닙니다."); + return; + } + + const string playerPrefabPath = "Assets/_Game/Prefabs/Player/Prefab_Player_Default.prefab"; + GameObject playerPrefab = AssetDatabase.LoadAssetAtPath(playerPrefabPath); + if (playerPrefab == null) + { + Debug.LogWarning($"[Debug] 플레이어 프리팹을 찾지 못했습니다: {playerPrefabPath}"); + return; + } + + int spawnedCount = 0; + foreach (ulong clientId in networkManager.ConnectedClientsIds) + { + bool alreadyExists = false; + var allPlayers = Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); + for (int i = 0; i < allPlayers.Length; i++) + { + if (allPlayers[i] != null && allPlayers[i].OwnerClientId == clientId) + { + alreadyExists = true; + break; + } + } + + if (alreadyExists) + continue; + + var go = Object.Instantiate(playerPrefab); + var no = go.GetComponent(); + if (no != null) + { + no.SpawnAsPlayerObject(clientId, true); + spawnedCount++; + } + else + { + Object.Destroy(go); + } + } + + Debug.Log($"[Debug] 플레이어 스폰 완료 | {spawnedCount}명 스폰 (총 연결: {networkManager.ConnectedClientsIds.Count})"); + } + [MenuItem("Tools/Colosseum/Debug/Log Local Player Status")] private static void LogLocalPlayerStatus() { diff --git a/Assets/_Game/Scripts/Player/PlayerAbnormalityDebugHUD.cs b/Assets/_Game/Scripts/Player/PlayerAbnormalityDebugHUD.cs index 1cd7d7fa..698a1754 100644 --- a/Assets/_Game/Scripts/Player/PlayerAbnormalityDebugHUD.cs +++ b/Assets/_Game/Scripts/Player/PlayerAbnormalityDebugHUD.cs @@ -131,6 +131,11 @@ namespace Colosseum.Player { RequestRespawnRpc(); } + + if (GUILayout.Button("부활", GUILayout.Height(24f))) + { + RequestReviveRpc(); + } GUILayout.EndHorizontal(); GUILayout.Space(6f); @@ -409,6 +414,21 @@ namespace Colosseum.Player networkController.Respawn(); } + [Rpc(SendTo.Server)] + private void RequestReviveRpc() + { + if (networkController == null) + return; + + if (!networkController.IsDead) + { + Debug.LogWarning("[AbnormalityDebugHUD] 부활: 사망 상태가 아닙니다."); + return; + } + + networkController.Revive(0.3f); + } + private bool ShouldEnableDebugHud() { #if UNITY_EDITOR diff --git a/Assets/_Game/Scripts/Player/PlayerNetworkController.cs b/Assets/_Game/Scripts/Player/PlayerNetworkController.cs index d9e74008..adfb5661 100644 --- a/Assets/_Game/Scripts/Player/PlayerNetworkController.cs +++ b/Assets/_Game/Scripts/Player/PlayerNetworkController.cs @@ -83,6 +83,7 @@ namespace Colosseum.Player public event Action OnDeath; public event Action OnDeathStateChanged; public event Action OnRespawned; + public event Action OnRevived; public event Action OnPassiveSelectionChanged; public float CurrentHealth => currentHealth.Value; @@ -249,7 +250,8 @@ namespace Colosseum.Player } /// - /// 사망 처리 (서버에서만 실행) + /// 사망(빈사) 처리 (서버에서만 실행). + /// HP가 0 이하가 되면 호출되며, 부활 스킬로 복귀 가능한 빈사 상태로 전환합니다. /// private void HandleDeath() { @@ -362,6 +364,36 @@ namespace Colosseum.Player Debug.Log($"[Player] Player {OwnerClientId} respawned!"); } + /// + /// 빈사 상태에서 부활 (서버에서만 실행) + /// + /// 부활 시 체력 비율 (0~1) + public void Revive(float healthPercent = 0.3f) + { + if (!IsServer || !isDead.Value) + return; + + isDead.Value = false; + float revivedHealth = Mathf.Max(1f, MaxHealth * Mathf.Clamp01(healthPercent)); + currentHealth.Value = revivedHealth; + + PlayerMovement movement = GetComponent(); + if (movement != null) + { + movement.enabled = true; + } + + PlayerSkillInput skillInput = GetComponent(); + if (skillInput != null) + { + skillInput.enabled = true; + } + + OnRevived?.Invoke(this); + + Debug.Log($"[Player] Player {OwnerClientId} revived! HP={revivedHealth:F0}"); + } + /// /// 서버 기준으로 패시브 프리셋을 적용합니다. /// diff --git a/Assets/_Game/Scripts/Player/PlayerSkillInput.cs b/Assets/_Game/Scripts/Player/PlayerSkillInput.cs index 046a2b06..5d9eb6ec 100644 --- a/Assets/_Game/Scripts/Player/PlayerSkillInput.cs +++ b/Assets/_Game/Scripts/Player/PlayerSkillInput.cs @@ -46,7 +46,7 @@ namespace Colosseum.Player "Assets/_Game/Data/Skills/Data_Skill_Player_보호막.asset", "Assets/_Game/Data/Skills/Data_Skill_Player_방어태세.asset", "Assets/_Game/Data/Skills/Data_Skill_Player_투사체.asset", - "Assets/_Game/Data/Skills/Data_Skill_Player_구르기.asset", + "Assets/_Game/Data/Skills/Data_Skill_Player_부활.asset", }; private static readonly string[] DpsLoadoutPaths = @@ -566,27 +566,48 @@ namespace Colosseum.Player public bool DebugExecuteSkillAsServer(int slotIndex) { if (!IsServer) + { + Debug.LogWarning($"[Debug] DebugExecuteSkillAsServer 실패: 서버가 아님 (IsServer=false)"); return false; + } EnsureRuntimeReferences(); if (slotIndex < 0 || slotIndex >= skillSlots.Length) + { + Debug.LogWarning($"[Debug] DebugExecuteSkillAsServer 실패: 슬롯 인덱스 범위 초과 (slotIndex={slotIndex}, length={skillSlots.Length})"); return false; + } SkillLoadoutEntry loadoutEntry = GetSkillLoadout(slotIndex); SkillData skill = loadoutEntry != null ? loadoutEntry.BaseSkill : null; if (skill == null) + { + Debug.LogWarning($"[Debug] DebugExecuteSkillAsServer 실패: 슬롯 {slotIndex}이 비어있음"); return false; + } if (actionState != null && !actionState.CanStartSkill(skill)) + { + Debug.LogWarning($"[Debug] DebugExecuteSkillAsServer 실패: CanStartSkill=false (IsDead={actionState.IsDead}, CanUseSkills={actionState.CanUseSkills})"); return false; + } if (skillController == null || skillController.IsExecutingSkill || skillController.IsOnCooldown(skill)) + { + string reason = skillController == null ? "skillController=null" : + skillController.IsExecutingSkill ? "스킬 실행 중" : + skillController.IsOnCooldown(skill) ? "쿨다운 중" : ""; + Debug.LogWarning($"[Debug] DebugExecuteSkillAsServer 실패: {reason}"); return false; + } float actualManaCost = GetActualManaCost(loadoutEntry); if (networkController != null && networkController.Mana < actualManaCost) + { + Debug.LogWarning($"[Debug] DebugExecuteSkillAsServer 실패: MP 부족 (need={actualManaCost:F1}, have={networkController.Mana:F1})"); return false; + } if (networkController != null && actualManaCost > 0f) { @@ -594,6 +615,7 @@ namespace Colosseum.Player } BroadcastSkillExecutionRpc(slotIndex); + Debug.Log($"[Debug] DebugExecuteSkillAsServer 성공: Slot={slotIndex}, Skill={skill.SkillName}"); return true; } diff --git a/Assets/_Game/Scripts/Skills/Effects/ReviveEffect.cs b/Assets/_Game/Scripts/Skills/Effects/ReviveEffect.cs new file mode 100644 index 00000000..28502dd2 --- /dev/null +++ b/Assets/_Game/Scripts/Skills/Effects/ReviveEffect.cs @@ -0,0 +1,49 @@ +using UnityEngine; + +using Colosseum.Player; + +namespace Colosseum.Skills.Effects +{ + /// + /// 빈사 상태인 아군을 부활시키는 스킬 효과입니다. + /// + [CreateAssetMenu(fileName = "ReviveEffect", menuName = "Colosseum/Skills/Effects/Revive")] + public class ReviveEffect : SkillEffect + { + [Header("Revive Settings")] + [Tooltip("부활 시 복구할 체력 비율 (0~1)")] + [Range(0f, 1f)] [SerializeField] private float healthPercent = 0.3f; + + /// + /// 부활 체력 비율 + /// + public float HealthPercent => healthPercent; + + /// + /// 부활 효과를 적용합니다. + /// + protected override void ApplyEffect(GameObject caster, GameObject target) + { + if (target == null) + { + Debug.LogWarning("[ReviveEffect] Target is null."); + return; + } + + PlayerNetworkController networkController = target.GetComponent(); + if (networkController == null) + { + Debug.LogWarning($"[ReviveEffect] PlayerNetworkController not found on target: {target.name}"); + return; + } + + if (!networkController.IsDead) + { + Debug.LogWarning($"[ReviveEffect] Target is not dead: {target.name}"); + return; + } + + networkController.Revive(healthPercent); + } + } +} diff --git a/Assets/_Game/Scripts/Skills/SkillController.cs b/Assets/_Game/Scripts/Skills/SkillController.cs index 4a116fd7..eb412cfb 100644 --- a/Assets/_Game/Scripts/Skills/SkillController.cs +++ b/Assets/_Game/Scripts/Skills/SkillController.cs @@ -18,6 +18,7 @@ namespace Colosseum.Skills Stun, HitReaction, Respawn, + Revive, } ///