2026-04-28 왼쪽 손목에 포만감 배치

This commit is contained in:
2026-04-28 17:06:13 +09:00
parent 07360de42c
commit 5f44249b4a
10 changed files with 248 additions and 2 deletions

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 013d891a04c503c448f3ad0c8bbff97a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0340c993b89f79d4295b22c393b0928c

View File

@@ -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<XRGrabInteractable>();
_sfxSource = GetComponent<AudioSource>();
}
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);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 109ec665348047944b5664025d3b2c5f

View File

@@ -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<float, float> 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;
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c258fae13eb64b545ba3da8ae330f535

View File

@@ -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)}";
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2f25be49768752d4596d4adf7819403d