diff --git a/Assets/01_Scenes/MyProject/GameScene.unity b/Assets/01_Scenes/MyProject/GameScene.unity index f886e498..b237bf42 100644 --- a/Assets/01_Scenes/MyProject/GameScene.unity +++ b/Assets/01_Scenes/MyProject/GameScene.unity @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ac55cace4bde63403168dd6144129c4a9d0b6a77f9e6c89893ad5be650626a70 -size 13697125 +oid sha256:6008d0c2f5a8930408b33a7ba919bd565a38e410fc4ee9817e8ca3ce4a6cc684 +size 13714107 diff --git a/Assets/02_Scripts/Data/Food.meta b/Assets/02_Scripts/Data/Food.meta new file mode 100644 index 00000000..37d4dfa7 --- /dev/null +++ b/Assets/02_Scripts/Data/Food.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 013d891a04c503c448f3ad0c8bbff97a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/02_Scripts/Data/Food/FoodData.cs b/Assets/02_Scripts/Data/Food/FoodData.cs new file mode 100644 index 00000000..583b40da --- /dev/null +++ b/Assets/02_Scripts/Data/Food/FoodData.cs @@ -0,0 +1,21 @@ +using UnityEngine; + +namespace VRShopping.Data.Food +{ + [CreateAssetMenu(fileName = "FoodData", menuName = "VR Shopping/Food Data", order = 1)] + public class FoodData : ScriptableObject + { + [Header("Identity")] + public string FoodId; + public string DisplayName; + + [Header("Effect")] + // 0~100 스케일 기준 회복량 + [Min(0f)] public float HungerRestoreAmount = 20f; + + [Header("Visuals/Audio")] + public GameObject SamplePrefab; + public ParticleSystem EatVfx; + public AudioClip EatSfx; + } +} diff --git a/Assets/02_Scripts/Data/Food/FoodData.cs.meta b/Assets/02_Scripts/Data/Food/FoodData.cs.meta new file mode 100644 index 00000000..20c5cda6 --- /dev/null +++ b/Assets/02_Scripts/Data/Food/FoodData.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0340c993b89f79d4295b22c393b0928c \ No newline at end of file diff --git a/Assets/02_Scripts/Item/TastingSample.cs b/Assets/02_Scripts/Item/TastingSample.cs new file mode 100644 index 00000000..ab376ae1 --- /dev/null +++ b/Assets/02_Scripts/Item/TastingSample.cs @@ -0,0 +1,64 @@ +using UnityEngine; +using UnityEngine.XR.Interaction.Toolkit.Interactables; +using VRShopping.Data.Food; +using VRShopping.Player; + +namespace VRShopping.Items +{ + // 시식 코너의 샘플. XRGrabInteractable로 잡을 수 있고, + // 잡은 상태에서 메인 카메라(=입) 근처로 가져가면 먹힌다. + [RequireComponent(typeof(XRGrabInteractable))] + public class TastingSample : MonoBehaviour + { + [SerializeField] private FoodData _foodData; + + // 입(메인 카메라)으로 인정할 거리 (m) + [SerializeField, Min(0.05f)] private float _eatDistance = 0.25f; + + // 잡혀있지 않아도 먹을 수 있게 할지 (기본은 잡은 상태에서만) + [SerializeField] private bool _requireGrabbed = true; + + private XRGrabInteractable _grab; + private AudioSource _sfxSource; + private bool _consumed; + + private void Awake() + { + _grab = GetComponent(); + _sfxSource = GetComponent(); + } + + private void Update() + { + if (_consumed || _foodData == null) return; + if (_requireGrabbed && (_grab == null || !_grab.isSelected)) return; + + var cam = Camera.main; + if (cam == null) return; + + float distSqr = (cam.transform.position - transform.position).sqrMagnitude; + if (distSqr <= _eatDistance * _eatDistance) Consume(); + } + + private void Consume() + { + _consumed = true; + + if (PlayerHunger.Instance != null) + PlayerHunger.Instance.Eat(_foodData.HungerRestoreAmount); + + // 손에서 강제 해제 (잡혀있던 상태로 Destroy되면 XRI 경고) + if (_grab != null && _grab.isSelected && _grab.interactionManager != null) + _grab.interactionManager.CancelInteractableSelection((UnityEngine.XR.Interaction.Toolkit.Interactables.IXRSelectInteractable)_grab); + + // SFX 재생 후 파괴 (사운드 길이 고려) + var sfx = _foodData.EatSfx; + if (sfx != null) + { + AudioSource.PlayClipAtPoint(sfx, transform.position); + } + + Destroy(gameObject); + } + } +} diff --git a/Assets/02_Scripts/Item/TastingSample.cs.meta b/Assets/02_Scripts/Item/TastingSample.cs.meta new file mode 100644 index 00000000..f2127444 --- /dev/null +++ b/Assets/02_Scripts/Item/TastingSample.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 109ec665348047944b5664025d3b2c5f \ No newline at end of file diff --git a/Assets/02_Scripts/Player/PlayerHunger.cs b/Assets/02_Scripts/Player/PlayerHunger.cs new file mode 100644 index 00000000..068b1c8d --- /dev/null +++ b/Assets/02_Scripts/Player/PlayerHunger.cs @@ -0,0 +1,105 @@ +using System; +using UnityEngine; +using UnityEngine.XR.Interaction.Toolkit.Locomotion.Movement; + +namespace VRShopping.Player +{ + // 플레이어 허기 게이지. 시간이 지나면 깎이고 0이 되면 이동 속도가 느려진다. + // Complete XR Origin Set Up 루트에 부착 (PlayerWallet과 동일 위치). + public class PlayerHunger : MonoBehaviour + { + public static PlayerHunger Instance { get; private set; } + + [Header("Gauge")] + [SerializeField, Min(0f)] private float _maxHunger = 100f; + [SerializeField, Min(0f)] private float _initialHunger = 80f; + [SerializeField, Min(0f)] private float _decayPerSecond = 1.5f; + + [Header("Slow Penalty")] + // 굶주린 상태에서의 이동 속도 (원래 속도와 곱해지지 않고 절댓값으로 대체) + [SerializeField, Min(0f)] private float _starvedMoveSpeed = 0.6f; + // ContinuousMoveProvider의 서브클래스(예: Starter Assets의 DynamicMoveProvider)도 그대로 할당 가능 + [SerializeField] private ContinuousMoveProvider _moveProvider; + + // 0~_maxHunger 사이의 현재 값 (UI 갱신용 이벤트로 통지) + public event Action OnHungerChanged; // (current, max) + + public float Current { get; private set; } + public float Max => _maxHunger; + public bool IsStarved => Current <= 0f; + + private float _originalMoveSpeed; + private bool _slowApplied; + + private void Awake() + { + if (Instance != null && Instance != this) { Destroy(this); return; } + Instance = this; + + Current = Mathf.Clamp(_initialHunger, 0f, _maxHunger); + + // MoveProvider 원본 속도 캐싱 + if (_moveProvider != null) _originalMoveSpeed = _moveProvider.moveSpeed; + } + + private void OnDestroy() + { + if (Instance == this) Instance = null; + // 씬 종료 시 변경한 속도 복구 + RestoreMoveSpeed(); + } + + private void Start() + { + OnHungerChanged?.Invoke(Current, _maxHunger); + } + + private void Update() + { + if (Current > 0f) + { + Current = Mathf.Max(0f, Current - _decayPerSecond * Time.deltaTime); + OnHungerChanged?.Invoke(Current, _maxHunger); + } + + UpdateSlowState(); + } + + // 시식 등으로 허기를 회복 + public void Eat(float amount) + { + if (amount <= 0f) return; + + Current = Mathf.Min(_maxHunger, Current + amount); + OnHungerChanged?.Invoke(Current, _maxHunger); + + UpdateSlowState(); + } + + private void UpdateSlowState() + { + if (_moveProvider == null) return; + + bool shouldSlow = IsStarved; + if (shouldSlow && !_slowApplied) + { + _moveProvider.moveSpeed = _starvedMoveSpeed; + _slowApplied = true; + } + else if (!shouldSlow && _slowApplied) + { + _moveProvider.moveSpeed = _originalMoveSpeed; + _slowApplied = false; + } + } + + private void RestoreMoveSpeed() + { + if (_moveProvider != null && _slowApplied) + { + _moveProvider.moveSpeed = _originalMoveSpeed; + _slowApplied = false; + } + } + } +} diff --git a/Assets/02_Scripts/Player/PlayerHunger.cs.meta b/Assets/02_Scripts/Player/PlayerHunger.cs.meta new file mode 100644 index 00000000..38f09425 --- /dev/null +++ b/Assets/02_Scripts/Player/PlayerHunger.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c258fae13eb64b545ba3da8ae330f535 \ No newline at end of file diff --git a/Assets/02_Scripts/UI/HungerHud.cs b/Assets/02_Scripts/UI/HungerHud.cs new file mode 100644 index 00000000..cf8b666c --- /dev/null +++ b/Assets/02_Scripts/UI/HungerHud.cs @@ -0,0 +1,40 @@ +using TMPro; +using UnityEngine; +using UnityEngine.UI; +using VRShopping.Player; + +namespace VRShopping.UI +{ + // 왼쪽 손목에 부착되는 허기 게이지 HUD. + // PlayerHunger.Instance에 자동 구독. + public class HungerHud : MonoBehaviour + { + [SerializeField] private Image _fillImage; // type=Filled 권장 + [SerializeField] private TMP_Text _text; // 옵션, "80 / 100" 형식 + [SerializeField] private PlayerHunger _bound; + + private void Start() + { + _bound.OnHungerChanged += HandleChanged; + HandleChanged(_bound.Current, _bound.Max); + } + + private void OnDestroy() + { + if (_bound != null) + { + _bound.OnHungerChanged -= HandleChanged; + _bound = null; + } + } + + private void HandleChanged(float current, float max) + { + if (_fillImage != null && max > 0f) + _fillImage.fillAmount = Mathf.Clamp01(current / max); + + if (_text != null) + _text.text = $"{Mathf.CeilToInt(current)} / {Mathf.CeilToInt(max)}"; + } + } +} diff --git a/Assets/02_Scripts/UI/HungerHud.cs.meta b/Assets/02_Scripts/UI/HungerHud.cs.meta new file mode 100644 index 00000000..e24c41d6 --- /dev/null +++ b/Assets/02_Scripts/UI/HungerHud.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2f25be49768752d4596d4adf7819403d \ No newline at end of file