diff --git a/Assets/KSH/PlayerStats.cs b/Assets/KSH/PlayerStats.cs index da3d4a9d..760b6f9b 100644 --- a/Assets/KSH/PlayerStats.cs +++ b/Assets/KSH/PlayerStats.cs @@ -2,6 +2,7 @@ using System; using System.Collections; using System.Collections.Generic; using UnityEngine; +using UnityEngine.SceneManagement; using Random = UnityEngine.Random; public class PlayerStats : MonoBehaviour @@ -29,7 +30,7 @@ public class PlayerStats : MonoBehaviour public event Action OnDayEnded; public event Action Exhaustion; // 탈진 - public event Action Overslept; // 결근(늦잠) + public event Action Overslept; // 늦잠 public event Action ZeroReputation; // 평판 0 이벤트 public event Action OnStatsChanged; // 스탯 변경 이벤트 public event Action OnWorked; // 퇴근 이벤트 (출근 이후 집에 돌아올 시간에 발생) @@ -37,6 +38,18 @@ public class PlayerStats : MonoBehaviour private float previousAddHealth = 0f; public static PlayerStats Instance; + + // 결근 이벤트 관련 변수 + private bool _hasWorkedToday = false; + private bool _hasCheckedAbsenceToday = false; // 결근 체크, 하루에 결근 여러 번 체크 안하기 위함 + public event Action OnAbsent; // 결근 + + // 말풍선 + private GameObject messagePanelInstance; + private SpeechBubbleFollower speechBubbleFollower; + private bool isActiveBubble; + private bool hasShownBubbleToday; // 하루에 말풍선 하나만 표시하기 + private void Awake() { if (Instance == null) @@ -59,7 +72,88 @@ public class PlayerStats : MonoBehaviour { _valueByAction = new ValueByAction(); _valueByAction.Initialize(); // 값 초기화 + + LoadMessagePanel(); + CheckBubble(); + + SceneManager.sceneLoaded += OnSceneLoaded; // 씬 전환 이벤트 } + + #region 말풍선(Bubble) 관련 + + private void OnSceneLoaded(Scene scene, LoadSceneMode mode) + { + // 새 씬에서 메시지 패널 다시 로드 + LoadMessagePanel(); + CheckBubble(); + } + + // OnDestroy에서 이벤트 구독 해제 + private void OnDestroy() + { + SceneManager.sceneLoaded -= OnSceneLoaded; + } + + private void LoadMessagePanel() + { + if (messagePanelInstance != null) // 기존 패널 파괴 + { + Destroy(messagePanelInstance); + messagePanelInstance = null; + } + + GameObject messagePanelPrefab = Resources.Load("Prefabs/MessagePanel"); + + if (messagePanelPrefab != null) + { + Canvas canvas = FindObjectOfType(); + + messagePanelInstance = Instantiate(messagePanelPrefab, canvas.transform); + speechBubbleFollower = messagePanelInstance.GetComponent(); + speechBubbleFollower.SetPlayerTransform(); + + if (speechBubbleFollower != null) + { + isActiveBubble = false; + hasShownBubbleToday = false; + speechBubbleFollower.HideMessage(); + } + } + } + + private void CheckBubble() + { + if (isActiveBubble) + { + isActiveBubble = false; + HideBubble(); + } + + if (TimeStat >= 8.0f && TimeStat < 9.0f && !isActiveBubble && !hasShownBubbleToday) + { + hasShownBubbleToday = true; + isActiveBubble = true; + ShowBubble(); + } + } + + public void ShowBubble() + { + if(isActiveBubble) + speechBubbleFollower.ShowMessage(); + } + + public void HideBubble() + { + speechBubbleFollower.HideMessage(); + } + + public void ShowAndHideBubble(string text) + { + speechBubbleFollower.ShowAndHide(text); + } + + #endregion // 현재 체력으로 해당 행동이 가능한 지 확인 public bool CanPerformByHealth(ActionType actionType) @@ -69,6 +163,23 @@ public class PlayerStats : MonoBehaviour return (HealthStat >= (effect.healthChange * -1)); } + // 결근 체크 + public void CheckAbsent() + { + if (_hasWorkedToday || _hasCheckedAbsenceToday) + return; + + // 9시가 지났는데 출근하지 않은 경우 + if (TimeStat >= 9.0f && !_hasWorkedToday) + { + _hasCheckedAbsenceToday = true; // 결근 체크 완료 표시 + OnAbsent?.Invoke(); + + PerformAction(ActionType.Absence); // 평판 -3 + Debug.Log("결근 처리: 평판 감소" + ReputationStat); + } + } + // 행동 처리 메서드 public void PerformAction(ActionType actionType) { @@ -86,6 +197,7 @@ public class PlayerStats : MonoBehaviour // 스탯 - 시간이 변경된 이후 퇴근 이벤트 발생 if (actionType == ActionType.Work) { + _hasWorkedToday = true; OnWorked?.Invoke(); } } @@ -156,6 +268,11 @@ public class PlayerStats : MonoBehaviour // 하루가 실제로 종료된 경우에만 이벤트 발생 if (isDayEnded) { + // 결근 관련 변수 초기화 + _hasWorkedToday = false; + _hasCheckedAbsenceToday = false; + hasShownBubbleToday = false; + OnDayEnded?.Invoke(); } } @@ -173,16 +290,20 @@ public class PlayerStats : MonoBehaviour return ( wakeUpTime + 24f ) - timeStat; // 다음 날 오전 8시까지 남은 시간 } } + + #region Modify Stats // 행동에 따른 내부 스탯 변경 메서드 public void ModifyTime(float time, ActionType actionType) { TimeStat += time; - + if (TimeStat >= _gameConstants.maxTime) { EndDay(time, actionType); } + + CheckBubble(); } public void ModifyHealth(float health) @@ -211,11 +332,17 @@ public class PlayerStats : MonoBehaviour public void ModifyReputation(float reputation) { // float 연산 시 계산 오차가 발생할 수도 있기에 소수점 두 번째에서 반올림하도록 처리 - ReputationStat = Mathf.Round((ReputationStat + reputation) * 100f) / 100f; + if(ReputationStat > 0) + { + ReputationStat = Mathf.Round((ReputationStat + reputation) * 100f) / 100f; + } + else + { + ReputationStat = 0f; + } if (ReputationStat <= 0) { - Debug.Log("당신의 평판은 0입니다..;"); ZeroReputation?.Invoke(); ReputationStat = 0.0f; } @@ -225,4 +352,6 @@ public class PlayerStats : MonoBehaviour ReputationStat = _gameConstants.maxReputation; } } + + #endregion } diff --git a/Assets/KSH/SpeechBubbleFollower.cs b/Assets/KSH/SpeechBubbleFollower.cs new file mode 100644 index 00000000..fc96edf8 --- /dev/null +++ b/Assets/KSH/SpeechBubbleFollower.cs @@ -0,0 +1,120 @@ +using System.Collections; +using UnityEngine; +using UnityEngine.UI; +using TMPro; + +public class SpeechBubbleFollower : MonoBehaviour +{ + [SerializeField] private Transform playerTransform; + [SerializeField] private TMP_Text bubbleText; + + private Vector3 offset = new Vector3(200f, 250f, 0); + + private Camera mainCamera; + private RectTransform rectTransform; + private CanvasGroup canvasGroup; + + private float minDistance = 3f; + private float maxDistance = 8f; + private float minOffsetScale = 0.7f; + + private Coroutine hideCoroutine; // 자동 숨김용 코루틴 + + // 랜덤 메시지 + private string[] workReminderMessages = new string[] + { + "8시.. 출근하자.", + "출근...해야 하나.", + "회사가 기다린다." + }; + + private void Awake() + { + rectTransform = GetComponent(); + canvasGroup = GetComponent(); + + if (canvasGroup == null) + canvasGroup = gameObject.AddComponent(); + + gameObject.SetActive(false); + } + + private void Start() + { + mainCamera = Camera.main; + SetPlayerTransform(); + } + + public void SetPlayerTransform() + { + if (playerTransform == null) + { + playerTransform = GameObject.FindGameObjectWithTag("Player").transform; + } + } + + private void LateUpdate() + { + if (!gameObject.activeInHierarchy || playerTransform == null) + return; + + // Z축 거리 계산 + float zDistance = Mathf.Abs(mainCamera.transform.position.z - playerTransform.position.z); + + // 거리에 따른 오프셋 비율 계산 (멀어질수록 작아짐) + float normalizedDistance = Mathf.Clamp01((zDistance - minDistance) / (maxDistance - minDistance)); + float offsetScale = Mathf.Lerp(1f, minOffsetScale, normalizedDistance); + + // 실제 적용할 오프셋 계산 + Vector3 scaledOffset = offset * offsetScale; + + // 플레이어 위치를 스크린 좌표로 변환 + Vector3 screenPosition = mainCamera.WorldToScreenPoint(playerTransform.position); + screenPosition.z = 0; + + // 위치 적용 + transform.position = screenPosition + scaledOffset; + } + + public void ShowMessage() // 랜덤 텍스트 표시 + { + string message = workReminderMessages[Random.Range(0, workReminderMessages.Length)]; + + if (bubbleText != null) + bubbleText.text = message; + + gameObject.SetActive(true); + canvasGroup.alpha = 1f; + } + + public void HideMessage() + { + gameObject.SetActive(false); + } + + public void ShowAndHide(string text) + { + // 텍스트 설정 + if (bubbleText != null) + bubbleText.text = text; + + // 말풍선 활성화 + gameObject.SetActive(true); + canvasGroup.alpha = 1f; + + // 이전에 실행 중인 코루틴이 있다면 중지 + if (hideCoroutine != null) + StopCoroutine(hideCoroutine); + + // 3초 후 자동 숨김 코루틴 시작 + hideCoroutine = StartCoroutine(HideAfterDelay(3f)); + } + + // 일정 시간 후 말풍선을 숨기는 코루틴 + private IEnumerator HideAfterDelay(float delay) + { + yield return new WaitForSeconds(delay); + HideMessage(); + hideCoroutine = null; + } +} \ No newline at end of file diff --git a/Assets/KSH/SpeechBubbleFollower.cs.meta b/Assets/KSH/SpeechBubbleFollower.cs.meta new file mode 100644 index 00000000..e74bd9d7 --- /dev/null +++ b/Assets/KSH/SpeechBubbleFollower.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 91cfaa5ec19c50b41ac2d6c542b51cd1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/LIN/Scripts/DailyRoutine/InteractionController.cs b/Assets/LIN/Scripts/DailyRoutine/InteractionController.cs index d984587b..39261270 100644 --- a/Assets/LIN/Scripts/DailyRoutine/InteractionController.cs +++ b/Assets/LIN/Scripts/DailyRoutine/InteractionController.cs @@ -10,8 +10,8 @@ public class InteractionController : MonoBehaviour { [SerializeField] LayerMask interactionLayerMask; - [Header("UI 연동")] [SerializeField] - HousingCanvasController housingCanvasController; + [Header("UI 연동")] + [SerializeField] private HousingCanvasController housingCanvasController; [SerializeField] private InteractionAnimationPanelController interactionAnimationPanelController; @@ -23,6 +23,7 @@ public class InteractionController : MonoBehaviour // 상호작용 가능한 사물 범위에 들어올 때 private void OnTriggerEnter(Collider other) { + PlayerStats.Instance.HideBubble(); if (other.gameObject.layer == LayerMask.NameToLayer("NPC")) { housingCanvasController.ShowNpcInteractionButton(() => @@ -30,7 +31,7 @@ public class InteractionController : MonoBehaviour GameManager.Instance.StartNPCDialogue(GamePhase.Gameplay); }); } - + if (interactionLayerMask == (interactionLayerMask | (1 << other.gameObject.layer))) { ActionType interactionType = other.gameObject.GetComponent().RoutineEnter(); @@ -48,39 +49,48 @@ public class InteractionController : MonoBehaviour if (interactionLayerMask == (interactionLayerMask | (1 << other.gameObject.layer))) { + PlayerStats.Instance.ShowBubble(); housingCanvasController.HideInteractionButton(); housingCanvasController.interactionTextsController.InitInteractionTexts(); } } - + // ActionType 별로 화면에 상호작용 내용 표시, 상호작용 버튼에 이벤트 작성 private void PopActionOnScreen(ActionType interactionType) { HousingConstants.interactions.TryGetValue(interactionType, out var interactionTexts); - - housingCanvasController.ShowInteractionButton(interactionTexts.ActionText, interactionTexts.DescriptionText, - () => + + housingCanvasController.ShowInteractionButton(interactionTexts.ActionText,interactionTexts.DescriptionText,()=> + { + if (PlayerStats.Instance.CanPerformByHealth(interactionType)) { - if (PlayerStats.Instance.CanPerformByHealth(interactionType)) + if (interactionType == ActionType.Work) { - PlayerStats.Instance.PerformAction(interactionType); - - if (interactionType == ActionType.Dungeon) + if (!PlayerStats.Instance.CanWork()) // 출근 가능한 시간이 아닐 경우 { - GameManager.Instance.ChangeToGameScene(); - } - else - { - GameManager.Instance.PlayInteractionSound(interactionType); - interactionAnimationPanelController.ShowAnimationPanel(interactionType, - interactionTexts.AnimationText); + PlayerStats.Instance.ShowAndHideBubble("출근 시간이 아냐"); + return; } } + + if (interactionType == ActionType.Dungeon) + { + GameManager.Instance.ChangeToGameScene(); + } else { - housingCanvasController.interactionTextsController.ActiveTexts(interactionTexts.LackOfHealth); + GameManager.Instance.PlayInteractionSound(interactionType); + interactionAnimationPanelController.ShowAnimationPanel(interactionType,interactionTexts.AnimationText); } - }); + + PlayerStats.Instance.PerformAction(interactionType); + } + else + { + PlayerStats.Instance.ShowAndHideBubble("체력이 없어..."); + housingCanvasController.interactionTextsController.ActiveTexts(interactionTexts.LackOfHealth); + } + }); } public Action SuddenEventHappen() diff --git a/Assets/LIN/Scripts/UI/InteractionAnimationPanelController.cs b/Assets/LIN/Scripts/UI/InteractionAnimationPanelController.cs index 71c3d216..db35f2c9 100644 --- a/Assets/LIN/Scripts/UI/InteractionAnimationPanelController.cs +++ b/Assets/LIN/Scripts/UI/InteractionAnimationPanelController.cs @@ -25,6 +25,8 @@ public class InteractionAnimationPanelController : MonoBehaviour public void ShowAnimationPanel(ActionType actionType, string animationText) { + PlayerStats.Instance.HideBubble(); + // 1) 패널 활성화 panel.SetActive(true); // 2) 기존 코루틴 정리 @@ -108,6 +110,10 @@ public class InteractionAnimationPanelController : MonoBehaviour StopCoroutine(_autoHideCoroutine); _autoHideCoroutine = null; } + + // 패널 닫히고 결근 체크, 상호작용 패널과 결근 엔딩 채팅창이 겹치지 않기 위함 + PlayerStats.Instance.CheckAbsent(); + PlayerStats.Instance.ShowBubble(); } public void TutorialSleepAnimation() diff --git a/Assets/Resources/Dialogues/dialogue.json b/Assets/Resources/Dialogues/dialogue.json index 915376fd..38b6326c 100644 --- a/Assets/Resources/Dialogues/dialogue.json +++ b/Assets/Resources/Dialogues/dialogue.json @@ -146,6 +146,55 @@ "text": "... GameManager.Instance.ShowCredit(GamePhase.End);", "nextId": "", "phase": "end" + }, + { + "id": "fairy_zero_1", + "name": "냉장고 요정", + "text": "...", + "nextId": "player_zero_1", + "phase": "zero" + }, + { + "id": "player_zero_1", + "name": "주인공", + "text": "...", + "nextId": "fairy_zero_2", + "phase": "zero" + }, + { + "id": "fairy_zero_2", + "name": "냉장고 요정", + "text": "평판이... 0?", + "nextId": "player_zero_2", + "phase": "zero" + }, + { + "id": "player_zero_2", + "name": "주인공", + "text": "...! (회사에서 연락이 온다.)", + "nextId": "fairy_zero_3", + "phase": "zero" + }, + { + "id": "fairy_zero_3", + "name": "회사", + "text": "당신은 해고되었습니다.", + "nextId": "player_zero_3", + "phase": "zero" + }, + { + "id": "player_zero_3", + "name": "주인공", + "text": "내가... 해고? 내가?", + "nextId": "fairy_zero_4", + "phase": "zero" + }, + { + "id": "fairy_zero_4", + "name": " ", + "text": "그 날 서울시 어느 동네에서, 한 34세의 남성의 절규 소리가 울려퍼졌다.", + "nextId": "", + "phase": "zero" } ] } \ No newline at end of file diff --git a/Assets/Resources/Prefabs/MessagePanel.prefab b/Assets/Resources/Prefabs/MessagePanel.prefab new file mode 100644 index 00000000..9ead4929 --- /dev/null +++ b/Assets/Resources/Prefabs/MessagePanel.prefab @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0001dccbde19204bdec40f7f14b3b0d39a39118757ff885b750fc00f34c402a9 +size 8961 diff --git a/Assets/Resources/Prefabs/MessagePanel.prefab.meta b/Assets/Resources/Prefabs/MessagePanel.prefab.meta new file mode 100644 index 00000000..a7e52caf --- /dev/null +++ b/Assets/Resources/Prefabs/MessagePanel.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 45ae814acb957c54785f24aefe6843e8 +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/Prefabs/Panels/PopupPanel.prefab b/Assets/Resources/Prefabs/Panels/PopupPanel.prefab index 7333bee8..648193be 100644 --- a/Assets/Resources/Prefabs/Panels/PopupPanel.prefab +++ b/Assets/Resources/Prefabs/Panels/PopupPanel.prefab @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ca3cb7132e5d70b4e5aefcaeaf99d150d9f3f086996fb957154946c0f7f15f49 -size 27212 +oid sha256:f29742631b2e266f5b736c1f407b479ba568f2b84fef610aac44e60c14d291ac +size 27180 diff --git a/Assets/Scripts/Common/Dialogue/ChatWindowController.cs b/Assets/Scripts/Common/Dialogue/ChatWindowController.cs index e1b808d8..428cd556 100644 --- a/Assets/Scripts/Common/Dialogue/ChatWindowController.cs +++ b/Assets/Scripts/Common/Dialogue/ChatWindowController.cs @@ -35,7 +35,8 @@ public enum GamePhase // 단계별로 출력되는 대화가 달라짐 { Intro, // 인트로 설명문 Gameplay, // 게임 진행 팁? 등 - End // 엔딩 대화 + End, // 엔딩 대화 + ZeroEnd } public class ChatWindowController : MonoBehaviour, IPointerClickHandler @@ -90,6 +91,7 @@ public class ChatWindowController : MonoBehaviour, IPointerClickHandler if (_inputQueue.Count > 0) { ShowNextDialogue(); + PlayerStats.Instance.HideBubble(); } } @@ -111,6 +113,8 @@ public class ChatWindowController : MonoBehaviour, IPointerClickHandler StopCoroutine(_clickCoroutine); _clickCoroutine = null; } + + PlayerStats.Instance.ShowBubble(); } #endregion diff --git a/Assets/Scripts/Common/Dialogue/FairyDialogueManager.cs b/Assets/Scripts/Common/Dialogue/FairyDialogueManager.cs index 86d5da0b..65fc370e 100644 --- a/Assets/Scripts/Common/Dialogue/FairyDialogueManager.cs +++ b/Assets/Scripts/Common/Dialogue/FairyDialogueManager.cs @@ -86,6 +86,10 @@ public class FairyDialogueManager else if (phase == GamePhase.End) { StartPhaseDialogue("end"); + } + else if (phase == GamePhase.ZeroEnd) + { + StartPhaseDialogue("zero"); } } diff --git a/Assets/Scripts/Common/GameManager.cs b/Assets/Scripts/Common/GameManager.cs index 42b1b1a2..871d1795 100644 --- a/Assets/Scripts/Common/GameManager.cs +++ b/Assets/Scripts/Common/GameManager.cs @@ -27,7 +27,11 @@ public partial class GameManager : Singleton { // 오디오 초기화 InitializeAudio(); - PlayerStats.Instance.OnDayEnded += AdvanceDay; + + // 이벤트 할당 + PlayerStats.Instance.OnDayEnded += AdvanceDay; // 날짜 변경 + PlayerStats.Instance.ZeroReputation += ZeroReputationEnd; // 평판 0 엔딩 + //패널 매니저 생성 panelManager = Instantiate(Resources.Load("Prefabs/PanelManager")).GetComponent(); } diff --git a/Assets/Scripts/Common/GameUtility/EndingLogic.cs b/Assets/Scripts/Common/GameUtility/EndingLogic.cs index 045b5935..8e77c8a8 100644 --- a/Assets/Scripts/Common/GameUtility/EndingLogic.cs +++ b/Assets/Scripts/Common/GameUtility/EndingLogic.cs @@ -16,9 +16,14 @@ public partial class GameManager public void ClearStage() { - Debug.Log($"스테이지 레벨 {stageLevel}을 클리어 하셨습니다!"); stageLevel++; } + + private void ZeroReputationEnd() + { + // npc와의 대화 출력, Phase = zero + StartNPCDialogue(GamePhase.ZeroEnd); + } // 엔딩 관련 메서드. 7일차에 실행 private void TriggerTimeEnding() @@ -27,10 +32,10 @@ public partial class GameManager StartNPCDialogue(GamePhase.End); // 플레이어 상태에 따라 엔딩 판별 - EndingType endingType = DetermineEnding(); + // EndingType endingType = DetermineEnding(); // 엔딩 타입에 따라 다른 씬이나 UI 표시 - switch (endingType) + /*switch (endingType) { case EndingType.Normal: Debug.Log("던전 공략 성공"); @@ -44,7 +49,7 @@ public partial class GameManager Debug.Log("던전 공략 성공과 훌륭한 평판 작"); break; - } + }*/ } // 던전 스테이지와 평판 수치로 엔딩 판별