2026-06-19 UI, UI로직

This commit is contained in:
skrwns304@gmail.com
2026-06-19 14:27:40 +09:00
parent b751a9ed66
commit b1e85a5b89
549 changed files with 18058 additions and 20 deletions

View File

@@ -0,0 +1,210 @@
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CatNoteSpawner : MonoBehaviour
{
[Header("References")]
[SerializeField] private RectTransform noteRoot;
[SerializeField] private RectTransform noteSpawnPoint;
[SerializeField] private RectTransform hitZone;
[Header("Note Prefabs")]
[SerializeField] private RhythmNote pawNotePrefab;
[SerializeField] private RhythmNote fishNotePrefab;
[SerializeField] private RhythmNote musicNotePrefab;
[Header("Spawn Settings")]
[SerializeField] private float normalSpawnInterval = 0.9f;
[SerializeField] private float easySpawnInterval = 1.2f;
[SerializeField] private float normalNoteSpeed = 360f;
[SerializeField] private float easyNoteSpeed = 300f;
[SerializeField] private float firstSpawnDelay = 0.5f;
[SerializeField] private float autoMissDistance = 90f;
[SerializeField] private int maxActiveNotes = 12;
[Header("Debug")]
[SerializeField] private bool showDebugLog = false;
private readonly List<RhythmNote> activeNotes = new List<RhythmNote>();
private Coroutine spawnRoutine;
private bool spawning;
private bool easyMode;
public event Action<RhythmNote> NoteAutoMissed;
public bool IsSpawning => spawning;
public IReadOnlyList<RhythmNote> ActiveNotes => activeNotes;
public void StartSpawn(bool useEasyMode)
{
StopSpawn(false);
easyMode = useEasyMode;
spawning = true;
spawnRoutine = StartCoroutine(SpawnRoutine());
}
public void StopSpawn(bool clearNotes = true)
{
spawning = false;
if (spawnRoutine != null)
{
StopCoroutine(spawnRoutine);
spawnRoutine = null;
}
if (clearNotes)
ClearNotes();
}
private IEnumerator SpawnRoutine()
{
yield return new WaitForSeconds(firstSpawnDelay);
while (spawning)
{
if (activeNotes.Count < maxActiveNotes)
SpawnNote();
yield return new WaitForSeconds(GetSpawnInterval());
}
}
public RhythmNote SpawnNote()
{
RhythmNote prefab = PickNotePrefab();
if (prefab == null)
{
Debug.LogWarning("[CatNoteSpawner] 사용할 노트 프리팹이 없습니다.");
return null;
}
if (noteRoot == null || noteSpawnPoint == null || hitZone == null)
{
Debug.LogWarning("[CatNoteSpawner] NoteRoot, NoteSpawnPoint, HitZone 중 연결되지 않은 값이 있습니다.");
return null;
}
RhythmNote note = Instantiate(prefab, noteRoot);
RectTransform noteRect = note.RectTransform;
noteRect.localScale = Vector3.one;
noteRect.localRotation = Quaternion.identity;
noteRect.localPosition = noteRoot.InverseTransformPoint(noteSpawnPoint.position);
float hitX = noteRoot.InverseTransformPoint(hitZone.position).x;
note.Initialize(GetNoteSpeed(), hitX, autoMissDistance);
note.PassedHitZone += OnNotePassedHitZone;
activeNotes.Add(note);
if (showDebugLog)
Debug.Log("[CatNoteSpawner] Note Spawned");
return note;
}
private RhythmNote PickNotePrefab()
{
List<RhythmNote> availablePrefabs = new List<RhythmNote>();
if (pawNotePrefab != null)
availablePrefabs.Add(pawNotePrefab);
if (fishNotePrefab != null)
availablePrefabs.Add(fishNotePrefab);
if (musicNotePrefab != null)
availablePrefabs.Add(musicNotePrefab);
if (availablePrefabs.Count == 0)
return null;
return availablePrefabs[UnityEngine.Random.Range(0, availablePrefabs.Count)];
}
private float GetSpawnInterval()
{
return easyMode ? easySpawnInterval : normalSpawnInterval;
}
private float GetNoteSpeed()
{
return easyMode ? easyNoteSpeed : normalNoteSpeed;
}
private void OnNotePassedHitZone(RhythmNote note)
{
NoteAutoMissed?.Invoke(note);
RemoveNote(note, true);
}
public RhythmNote GetClosestNote()
{
CleanupNullNotes();
RhythmNote closest = null;
float closestDistance = float.MaxValue;
for (int i = 0; i < activeNotes.Count; i++)
{
RhythmNote note = activeNotes[i];
if (note == null || note.IsJudged)
continue;
float distance = note.GetDistanceToHitZone();
if (distance < closestDistance)
{
closest = note;
closestDistance = distance;
}
}
return closest;
}
public void RemoveNote(RhythmNote note, bool destroyObject)
{
if (note == null)
return;
note.PassedHitZone -= OnNotePassedHitZone;
activeNotes.Remove(note);
if (destroyObject)
note.Remove();
else
note.MarkJudged();
}
public void ClearNotes()
{
for (int i = activeNotes.Count - 1; i >= 0; i--)
{
RhythmNote note = activeNotes[i];
if (note == null)
continue;
note.PassedHitZone -= OnNotePassedHitZone;
note.Remove();
}
activeNotes.Clear();
}
private void CleanupNullNotes()
{
for (int i = activeNotes.Count - 1; i >= 0; i--)
{
if (activeNotes[i] == null)
activeNotes.RemoveAt(i);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 07ecee837dba4c046b3036198781e371

View File

@@ -0,0 +1,323 @@
using UnityEngine;
using UnityEngine.Events;
public class RhythmGameManager : MonoBehaviour
{
public enum ResultType
{
Perfect,
Good,
Miss
}
[Header("References")]
[SerializeField] private RhythmGameUI ui;
[SerializeField] private CatNoteSpawner noteSpawner;
[SerializeField] private RhythmHapticManager haptic;
[Header("Game Settings")]
[SerializeField] private string songTitle = "고양이 합창단의 노래";
[SerializeField] private bool startOnAwake = false;
[SerializeField] private bool useFishBonusOnStart = false;
[Header("Rules")]
[SerializeField] private int requiredScore = 1000;
[SerializeField] private int allowedMisses = 5;
[SerializeField] private int perfectScore = 150;
[SerializeField] private int goodScore = 80;
[Header("Judgment Distance")]
[Tooltip("HitZone 중심과 노트 중심의 거리입니다. 값이 작을수록 판정이 어려워집니다.")]
[SerializeField] private float perfectDistance = 35f;
[SerializeField] private float goodDistance = 75f;
[SerializeField] private float missRemoveDistance = 120f;
[Header("Events")]
public UnityEvent onGameStarted;
public UnityEvent onGameSuccess;
public UnityEvent onGameFailed;
public UnityEvent onPerfect;
public UnityEvent onGood;
public UnityEvent onMiss;
[Header("Debug")]
[SerializeField] private bool showDebugLog = true;
private int score;
private int combo;
private int missCount;
private bool activeGame;
private bool fishBonusActive;
public int Score => score;
public int Combo => combo;
public int MissCount => missCount;
public bool IsPlaying => activeGame;
public bool FishBonusActive => fishBonusActive;
private void OnEnable()
{
if (noteSpawner != null)
noteSpawner.NoteAutoMissed += HandleAutoMiss;
}
private void OnDisable()
{
if (noteSpawner != null)
noteSpawner.NoteAutoMissed -= HandleAutoMiss;
}
private void Start()
{
if (startOnAwake)
StartGame(useFishBonusOnStart);
else
InitializeIdleState();
}
private void InitializeIdleState()
{
score = 0;
combo = 0;
missCount = 0;
activeGame = false;
if (ui != null)
{
ui.InitializeUI(songTitle);
ui.ShowFishBonus(false);
}
if (noteSpawner != null)
noteSpawner.StopSpawn(true);
}
public void StartGame()
{
StartGame(false);
}
public void StartGameWithFishBonus()
{
StartGame(true);
}
public void StartGame(bool useFishBonus)
{
score = 0;
combo = 0;
missCount = 0;
fishBonusActive = useFishBonus;
activeGame = true;
if (ui != null)
{
ui.InitializeUI(songTitle);
ui.ShowFishBonus(fishBonusActive);
ui.UpdateScore(score);
ui.UpdateCombo(combo);
}
if (noteSpawner != null)
noteSpawner.StartSpawn(fishBonusActive);
onGameStarted?.Invoke();
if (showDebugLog)
Debug.Log("[RhythmGameManager] 리듬게임 시작");
}
public void SubmitHit()
{
if (!activeGame)
return;
if (noteSpawner == null)
{
RegisterMiss(null, false);
return;
}
RhythmNote closestNote = noteSpawner.GetClosestNote();
if (closestNote == null)
{
RegisterMiss(null, false);
return;
}
float distance = closestNote.GetDistanceToHitZone();
ResultType result = EvaluateResult(distance);
switch (result)
{
case ResultType.Perfect:
RegisterPerfect(closestNote);
break;
case ResultType.Good:
RegisterGood(closestNote);
break;
case ResultType.Miss:
bool removeNote = distance <= missRemoveDistance;
RegisterMiss(closestNote, removeNote);
break;
}
CheckGameEnd();
}
private ResultType EvaluateResult(float distance)
{
if (distance <= perfectDistance)
return ResultType.Perfect;
if (distance <= goodDistance)
return ResultType.Good;
return ResultType.Miss;
}
private void RegisterPerfect(RhythmNote note)
{
score += perfectScore;
combo++;
if (noteSpawner != null)
noteSpawner.RemoveNote(note, true);
if (ui != null)
{
ui.UpdateScore(score);
ui.UpdateCombo(combo);
ui.ShowJudgment("완벽!");
}
if (haptic != null)
haptic.Perfect();
onPerfect?.Invoke();
if (showDebugLog)
Debug.Log("[RhythmGameManager] Perfect");
}
private void RegisterGood(RhythmNote note)
{
score += goodScore;
combo++;
if (noteSpawner != null)
noteSpawner.RemoveNote(note, true);
if (ui != null)
{
ui.UpdateScore(score);
ui.UpdateCombo(combo);
ui.ShowJudgment("좋아!");
}
if (haptic != null)
haptic.Good();
onGood?.Invoke();
if (showDebugLog)
Debug.Log("[RhythmGameManager] Good");
}
private void RegisterMiss(RhythmNote note, bool removeNote)
{
missCount++;
combo = 0;
if (removeNote && noteSpawner != null && note != null)
noteSpawner.RemoveNote(note, true);
if (ui != null)
{
ui.UpdateCombo(combo);
ui.ShowJudgment("실패!");
}
if (haptic != null)
haptic.Miss();
onMiss?.Invoke();
if (showDebugLog)
Debug.Log($"[RhythmGameManager] Miss {missCount}/{allowedMisses}");
}
private void HandleAutoMiss(RhythmNote note)
{
if (!activeGame)
return;
RegisterMiss(note, false);
CheckGameEnd();
}
private void CheckGameEnd()
{
if (!activeGame)
return;
if (score >= requiredScore)
{
GameSuccess();
return;
}
if (missCount >= allowedMisses)
{
GameFail();
}
}
private void GameSuccess()
{
activeGame = false;
if (noteSpawner != null)
noteSpawner.StopSpawn(true);
if (ui != null)
ui.ShowFinalResult("리듬 성공!");
onGameSuccess?.Invoke();
if (showDebugLog)
Debug.Log("[RhythmGameManager] 리듬게임 성공");
}
private void GameFail()
{
activeGame = false;
if (noteSpawner != null)
noteSpawner.StopSpawn(true);
if (ui != null)
ui.ShowFinalResult("리듬 실패!");
onGameFailed?.Invoke();
if (showDebugLog)
Debug.Log("[RhythmGameManager] 리듬게임 실패");
}
public void StopGame()
{
activeGame = false;
if (noteSpawner != null)
noteSpawner.StopSpawn(true);
}
public void ResetGame()
{
StartGame(fishBonusActive);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 547419d18208b11499a3e4597da9cfe0

View File

@@ -0,0 +1,155 @@
using System.Collections;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
public class RhythmGameUI : MonoBehaviour
{
[Header("Panel")]
[SerializeField] private GameObject rhythmPanel;
[SerializeField] private GameObject fishBonusPanel;
[SerializeField] private GameObject finalResultPanel;
[Header("Text")]
[SerializeField] private TMP_Text songTitleText;
[SerializeField] private TMP_Text scoreText;
[SerializeField] private TMP_Text comboText;
[SerializeField] private TMP_Text judgmentText;
[SerializeField] private TMP_Text fishBonusText;
[SerializeField] private TMP_Text guideText;
[SerializeField] private TMP_Text finalResultText;
[Header("Optional Images")]
[SerializeField] private Image fishIcon;
[Header("Settings")]
[SerializeField] private string defaultSongTitle = "고양이 합창단의 노래";
[SerializeField] private string defaultGuideText = "박자에 맞춰 양동이를 두드리세요";
[SerializeField] private float judgmentShowTime = 0.45f;
[SerializeField] private bool hidePanelOnFinalResult = false;
private Coroutine judgmentRoutine;
private void Awake()
{
InitializeUI(defaultSongTitle);
}
public void InitializeUI(string songTitle = null)
{
SetPanelVisible(true);
HideFinalResult();
HideJudgment();
ShowFishBonus(false);
SetSongTitle(string.IsNullOrWhiteSpace(songTitle) ? defaultSongTitle : songTitle);
UpdateScore(0);
UpdateCombo(0);
SetGuideText(defaultGuideText);
}
public void SetPanelVisible(bool visible)
{
if (rhythmPanel != null)
rhythmPanel.SetActive(visible);
}
public void SetSongTitle(string title)
{
if (songTitleText != null)
songTitleText.text = title;
}
public void UpdateScore(int score)
{
if (scoreText != null)
scoreText.text = $"점수 {score}";
}
public void UpdateCombo(int combo)
{
if (comboText != null)
comboText.text = $"{combo} COMBO";
}
public void SetGuideText(string text)
{
if (guideText != null)
guideText.text = text;
}
public void ShowFishBonus(bool active)
{
if (fishBonusPanel != null)
fishBonusPanel.SetActive(active);
if (fishIcon != null)
fishIcon.gameObject.SetActive(active);
if (fishBonusText != null)
{
fishBonusText.gameObject.SetActive(active);
fishBonusText.text = active ? "생선 사용됨! 난이도 감소" : string.Empty;
}
}
public void ShowJudgment(string text)
{
if (judgmentText == null)
return;
if (judgmentRoutine != null)
StopCoroutine(judgmentRoutine);
judgmentRoutine = StartCoroutine(JudgmentRoutine(text));
}
private IEnumerator JudgmentRoutine(string text)
{
judgmentText.gameObject.SetActive(true);
judgmentText.text = text;
yield return new WaitForSeconds(judgmentShowTime);
judgmentText.gameObject.SetActive(false);
judgmentRoutine = null;
}
public void HideJudgment()
{
if (judgmentRoutine != null)
{
StopCoroutine(judgmentRoutine);
judgmentRoutine = null;
}
if (judgmentText != null)
judgmentText.gameObject.SetActive(false);
}
public void ShowFinalResult(string text)
{
HideJudgment();
if (hidePanelOnFinalResult)
SetPanelVisible(false);
if (finalResultPanel != null)
finalResultPanel.SetActive(true);
if (finalResultText != null)
{
finalResultText.gameObject.SetActive(true);
finalResultText.text = text;
}
}
public void HideFinalResult()
{
if (finalResultPanel != null)
finalResultPanel.SetActive(false);
if (finalResultText != null)
finalResultText.gameObject.SetActive(false);
}
}

View File

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

View File

@@ -0,0 +1,54 @@
using UnityEngine;
using UnityEngine.XR;
public class RhythmHapticManager : MonoBehaviour
{
[Header("Haptic Target")]
[SerializeField] private XRNode targetHand = XRNode.RightHand;
[Header("Haptic Settings")]
[SerializeField] private bool useHaptic = true;
[SerializeField] private float perfectAmplitude = 0.85f;
[SerializeField] private float perfectDuration = 0.12f;
[SerializeField] private float goodAmplitude = 0.5f;
[SerializeField] private float goodDuration = 0.08f;
[SerializeField] private float missAmplitude = 0.2f;
[SerializeField] private float missDuration = 0.05f;
private void SendHaptic(float amplitude, float duration)
{
if (!useHaptic)
return;
InputDevice device = InputDevices.GetDeviceAtXRNode(targetHand);
if (!device.isValid)
return;
if (device.TryGetHapticCapabilities(out HapticCapabilities capabilities))
{
if (!capabilities.supportsImpulse)
return;
}
device.SendHapticImpulse(0u, Mathf.Clamp01(amplitude), duration);
}
public void Perfect()
{
SendHaptic(perfectAmplitude, perfectDuration);
}
public void Good()
{
SendHaptic(goodAmplitude, goodDuration);
}
public void Miss()
{
SendHaptic(missAmplitude, missDuration);
}
}

View File

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

View File

@@ -0,0 +1,52 @@
using UnityEngine;
using UnityEngine.InputSystem;
public class RhythmInputHandler : MonoBehaviour
{
[Header("Reference")]
[SerializeField] private RhythmGameManager rhythmGameManager;
[Header("Keyboard Test")]
[SerializeField] private bool allowKeyboardTest = true;
[SerializeField] private Key testKey = Key.Space;
[Header("Auto Find")]
[SerializeField] private bool autoFindManager = true;
private void Awake()
{
if (rhythmGameManager == null && autoFindManager)
rhythmGameManager = FindFirstObjectByType<RhythmGameManager>();
}
private void Update()
{
if (!allowKeyboardTest)
return;
if (Keyboard.current == null)
return;
if (Keyboard.current[testKey].wasPressedThisFrame)
SubmitHit();
}
public void OnHit(InputValue value)
{
if (value.isPressed)
SubmitHit();
}
public void SubmitHit()
{
if (rhythmGameManager == null)
return;
rhythmGameManager.SubmitHit();
}
public void SetManager(RhythmGameManager manager)
{
rhythmGameManager = manager;
}
}

View File

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

View File

@@ -0,0 +1,96 @@
using System;
using UnityEngine;
public enum RhythmNoteType
{
Paw,
Fish,
Music
}
[RequireComponent(typeof(RectTransform))]
public class RhythmNote : MonoBehaviour
{
[Header("Note")]
[SerializeField] private RhythmNoteType noteType = RhythmNoteType.Paw;
[Header("Runtime")]
[SerializeField] private float moveSpeed = 360f;
[SerializeField] private float hitZoneLocalX;
[SerializeField] private float autoMissDistance = 90f;
private RectTransform rectTransform;
private bool initialized;
private bool judged;
public RhythmNoteType NoteType => noteType;
public bool IsJudged => judged;
public float HitZoneLocalX => hitZoneLocalX;
public RectTransform RectTransform => rectTransform;
public event Action<RhythmNote> PassedHitZone;
private void Awake()
{
rectTransform = GetComponent<RectTransform>();
}
private void Update()
{
if (!initialized || judged)
return;
MoveNote();
CheckAutoMiss();
}
public void Initialize(float speed, float targetHitZoneLocalX, float missDistance)
{
if (rectTransform == null)
rectTransform = GetComponent<RectTransform>();
moveSpeed = speed;
hitZoneLocalX = targetHitZoneLocalX;
autoMissDistance = Mathf.Max(0f, missDistance);
judged = false;
initialized = true;
gameObject.SetActive(true);
}
private void MoveNote()
{
Vector2 position = rectTransform.anchoredPosition;
position.x += moveSpeed * Time.deltaTime;
rectTransform.anchoredPosition = position;
}
private void CheckAutoMiss()
{
if (rectTransform.anchoredPosition.x <= hitZoneLocalX + autoMissDistance)
return;
MarkJudged();
PassedHitZone?.Invoke(this);
}
public float GetDistanceToHitZone()
{
if (rectTransform == null)
rectTransform = GetComponent<RectTransform>();
return Mathf.Abs(rectTransform.anchoredPosition.x - hitZoneLocalX);
}
public void MarkJudged()
{
judged = true;
initialized = false;
}
public void Remove()
{
MarkJudged();
Destroy(gameObject);
}
}

View File

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