Merge pull request '리듬게임' (#5) from CatsRhythmGame into main
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
8
Assets/02_Scripts/Core.meta
Normal file
8
Assets/02_Scripts/Core.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 47b192809f9e2fc409f9ebeeff62cf64
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
43
Assets/02_Scripts/Core/Util.cs
Normal file
43
Assets/02_Scripts/Core/Util.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using UnityEngine;
|
||||
|
||||
public static class Util
|
||||
{
|
||||
/// 특정 시간(초) 후에 액션을 실행
|
||||
public static async Awaitable RunDelayed(float delay, Action action, CancellationToken token = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 유니티 전용 비동기 대기
|
||||
await Awaitable.WaitForSecondsAsync(delay, token);
|
||||
|
||||
// 함수 실행
|
||||
action?.Invoke();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 취소되었을 때의 처리 (필요 시)
|
||||
}
|
||||
}
|
||||
|
||||
/// 다음 프레임에 액션을 실행
|
||||
public static async Awaitable RunNextFrame(Action action, CancellationToken token = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Awaitable.EndOfFrameAsync(token);
|
||||
action?.Invoke();
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
public static float NormalizeAngle(float angle)
|
||||
{
|
||||
while (angle > 180)
|
||||
angle -= 360;
|
||||
while (angle < -180)
|
||||
angle += 360;
|
||||
return angle;
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Core/Util.cs.meta
Normal file
2
Assets/02_Scripts/Core/Util.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 414cb910207b7644a8bbb9be341333df
|
||||
184
Assets/02_Scripts/Data/Rhythm/MidiChartParser.cs
Normal file
184
Assets/02_Scripts/Data/Rhythm/MidiChartParser.cs
Normal file
@@ -0,0 +1,184 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
// 표준 MIDI 파일(.mid)을 파싱해 Note 리스트로 변환하는 미니 파서.
|
||||
// 단일 템포 가정. Note-On(velocity>0) 이벤트만 사용한다.
|
||||
public static class MidiChartParser
|
||||
{
|
||||
// data: .mid 원본 바이트 / offset: 곡 시작 보정(초)
|
||||
// bpmOverride: 0이면 MIDI 내장 템포 사용, >0이면 이 BPM으로 강제
|
||||
// lanePitches: 비우면 모든 노트를 레인 0으로, 채우면 해당 음높이만 레인으로 매핑
|
||||
public static List<Note> Parse(byte[] data, float offset, float bpmOverride, List<int> lanePitches)
|
||||
{
|
||||
var notes = new List<Note>();
|
||||
if (data == null || data.Length < 14)
|
||||
{
|
||||
Debug.LogWarning("[MidiChartParser] MIDI 데이터가 비었거나 너무 짧습니다.");
|
||||
return notes;
|
||||
}
|
||||
|
||||
int p = 0;
|
||||
|
||||
// ---- 헤더 청크 (MThd) ----
|
||||
if (!MatchId(data, p, 'M', 'T', 'h', 'd'))
|
||||
{
|
||||
Debug.LogWarning("[MidiChartParser] MThd 헤더가 아닙니다. (TextAsset이 .mid가 맞는지 확인)");
|
||||
return notes;
|
||||
}
|
||||
p += 4;
|
||||
ReadUInt32(data, ref p); // 헤더 길이(=6), 사용 안 함
|
||||
ReadUInt16(data, ref p); // 포맷
|
||||
int trackCount = ReadUInt16(data, ref p);
|
||||
int division = ReadUInt16(data, ref p);
|
||||
|
||||
if ((division & 0x8000) != 0)
|
||||
Debug.LogWarning("[MidiChartParser] SMPTE 타임코드는 미지원입니다. PPQN으로 가정합니다.");
|
||||
int ticksPerQuarter = division & 0x7FFF;
|
||||
if (ticksPerQuarter <= 0) ticksPerQuarter = 480;
|
||||
|
||||
// (절대틱, 음높이) 수집 + 첫 템포
|
||||
var rawNotes = new List<(int tick, int pitch)>();
|
||||
int tempoMicros = -1; // 4분음표당 마이크로초 (미발견 시 -1)
|
||||
|
||||
// ---- 트랙 청크들 (MTrk) ----
|
||||
for (int t = 0; t < trackCount && p + 8 <= data.Length; t++)
|
||||
{
|
||||
if (!MatchId(data, p, 'M', 'T', 'r', 'k'))
|
||||
break;
|
||||
p += 4;
|
||||
long len = ReadUInt32(data, ref p);
|
||||
int trackEnd = p + (int)len;
|
||||
if (trackEnd > data.Length) trackEnd = data.Length;
|
||||
|
||||
int absTicks = 0;
|
||||
int runningStatus = 0;
|
||||
|
||||
while (p < trackEnd)
|
||||
{
|
||||
absTicks += ReadVarLen(data, ref p);
|
||||
|
||||
int status = data[p];
|
||||
if (status < 0x80)
|
||||
{
|
||||
status = runningStatus; // 러닝 스테이터스: 상태 바이트 생략
|
||||
if (status == 0) { p = trackEnd; break; } // 손상된 데이터 방어
|
||||
}
|
||||
else
|
||||
{
|
||||
p++;
|
||||
runningStatus = status;
|
||||
}
|
||||
|
||||
if (status == 0xFF) // 메타 이벤트
|
||||
{
|
||||
int metaType = data[p++];
|
||||
int mlen = ReadVarLen(data, ref p);
|
||||
if (metaType == 0x51 && mlen == 3 && tempoMicros < 0)
|
||||
tempoMicros = (data[p] << 16) | (data[p + 1] << 8) | data[p + 2];
|
||||
p += mlen;
|
||||
runningStatus = 0;
|
||||
}
|
||||
else if (status == 0xF0 || status == 0xF7) // SysEx
|
||||
{
|
||||
int slen = ReadVarLen(data, ref p);
|
||||
p += slen;
|
||||
runningStatus = 0;
|
||||
}
|
||||
else // 채널 메시지
|
||||
{
|
||||
int type = status & 0xF0;
|
||||
switch (type)
|
||||
{
|
||||
case 0x90: // Note-On
|
||||
int pitch = data[p++];
|
||||
int velocity = data[p++];
|
||||
if (velocity > 0) rawNotes.Add((absTicks, pitch));
|
||||
break;
|
||||
case 0x80: // Note-Off
|
||||
case 0xA0: // 폴리 애프터터치
|
||||
case 0xB0: // 컨트롤 체인지
|
||||
case 0xE0: // 피치 벤드
|
||||
p += 2;
|
||||
break;
|
||||
case 0xC0: // 프로그램 체인지
|
||||
case 0xD0: // 채널 애프터터치
|
||||
p += 1;
|
||||
break;
|
||||
default:
|
||||
p = trackEnd; // 알 수 없는 상태 → 트랙 종료(무한루프 방지)
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p = trackEnd; // 트랙 경계 보정
|
||||
}
|
||||
|
||||
// [임시 디버그] MIDI에 실제로 들어있는 고유 음높이 확인 (LanePitches 번호 맞춘 뒤 삭제)
|
||||
var uniquePitches = new HashSet<int>();
|
||||
foreach (var raw in rawNotes) uniquePitches.Add(raw.pitch);
|
||||
var sortedPitches = new List<int>(uniquePitches);
|
||||
sortedPitches.Sort();
|
||||
Debug.Log($"[MidiChartParser] 발견된 고유 pitch: {string.Join(", ", sortedPitches)} (노트 {rawNotes.Count}개)");
|
||||
|
||||
// ---- 틱 → 초 변환 ----
|
||||
double usPerQuarter = bpmOverride > 0f
|
||||
? 60000000.0 / bpmOverride
|
||||
: (tempoMicros > 0 ? tempoMicros : 500000.0); // 기본 120 BPM
|
||||
double secPerTick = usPerQuarter / 1000000.0 / ticksPerQuarter;
|
||||
|
||||
bool useLanes = lanePitches != null && lanePitches.Count > 0;
|
||||
|
||||
foreach (var raw in rawNotes)
|
||||
{
|
||||
int lane = 0;
|
||||
if (useLanes)
|
||||
{
|
||||
lane = lanePitches.IndexOf(raw.pitch);
|
||||
if (lane < 0) continue; // 레인에 매핑 안 된 음높이는 무시
|
||||
}
|
||||
|
||||
notes.Add(new Note
|
||||
{
|
||||
Time = offset + (float)(raw.tick * secPerTick),
|
||||
Lane = lane
|
||||
});
|
||||
}
|
||||
|
||||
notes.Sort((a, b) => a.Time.CompareTo(b.Time));
|
||||
return notes;
|
||||
}
|
||||
|
||||
// ---- 바이트 읽기 헬퍼 (MIDI는 빅엔디안) ----
|
||||
|
||||
private static bool MatchId(byte[] d, int p, char a, char b, char c, char e)
|
||||
=> p + 4 <= d.Length && d[p] == a && d[p + 1] == b && d[p + 2] == c && d[p + 3] == e;
|
||||
|
||||
private static uint ReadUInt32(byte[] d, ref int p)
|
||||
{
|
||||
uint v = (uint)((d[p] << 24) | (d[p + 1] << 16) | (d[p + 2] << 8) | d[p + 3]);
|
||||
p += 4;
|
||||
return v;
|
||||
}
|
||||
|
||||
private static int ReadUInt16(byte[] d, ref int p)
|
||||
{
|
||||
int v = (d[p] << 8) | d[p + 1];
|
||||
p += 2;
|
||||
return v;
|
||||
}
|
||||
|
||||
// 가변 길이 수치(Variable-Length Quantity)
|
||||
private static int ReadVarLen(byte[] d, ref int p)
|
||||
{
|
||||
int value = 0;
|
||||
byte b;
|
||||
do
|
||||
{
|
||||
b = d[p++];
|
||||
value = (value << 7) | (b & 0x7F);
|
||||
}
|
||||
while ((b & 0x80) != 0 && p < d.Length);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Data/Rhythm/MidiChartParser.cs.meta
Normal file
2
Assets/02_Scripts/Data/Rhythm/MidiChartParser.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4930b4a6230ad694da5ca148280f1d07
|
||||
@@ -3,22 +3,28 @@
|
||||
|
||||
public class Note
|
||||
{
|
||||
[HideInInspector] public float Time;
|
||||
[HideInInspector] public float Time; // 판정 시각(초)
|
||||
[HideInInspector] public int Lane; // 레인 인덱스 (단일 레인이면 0)
|
||||
}
|
||||
|
||||
[CreateAssetMenu(fileName = "RhythmChart", menuName = "CatsRoom/RhythmChart")]
|
||||
public class RhythmChart : ScriptableObject
|
||||
{
|
||||
public AudioClip SongClip; // 노래 파일
|
||||
public float Bpm; // 분당 박자
|
||||
public float Offset; // 첫 박자 시작 시각
|
||||
public int NoteCount; // 노트 개수 (또는 곡 길이로 자동)
|
||||
public List<Note> GenerateNotes() // BPM·offset으로 시간 목록 자동 생성
|
||||
public AudioClip SongClip; // 노래 파일
|
||||
public TextAsset MidiFile; // FL Studio에서 내보낸 .mid (확장자를 .bytes로 임포트)
|
||||
public float Offset; // 곡 시작과 MIDI 0틱 사이 보정(초)
|
||||
public float BpmOverride; // 0이면 MIDI 내장 템포 사용, >0이면 이 BPM으로 강제
|
||||
public List<int> LanePitches = new(); // 비우면 모든 노트를 레인 0으로. 채우면 (index=레인) 매핑
|
||||
|
||||
// MIDI를 파싱해 노트 시간 목록 생성
|
||||
public List<Note> GenerateNotes()
|
||||
{
|
||||
List<Note> notes = new List<Note>();
|
||||
float interval = 60f / Bpm;
|
||||
for (int i = 0; i < NoteCount; i++)
|
||||
notes.Add(new Note { Time = Offset + interval * i });
|
||||
return notes;
|
||||
if (MidiFile == null)
|
||||
{
|
||||
Debug.LogWarning($"[RhythmChart] {name}: MidiFile이 비어 있습니다.");
|
||||
return new List<Note>();
|
||||
}
|
||||
|
||||
return MidiChartParser.Parse(MidiFile.bytes, Offset, BpmOverride, LanePitches);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,50 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
|
||||
public enum Result { Perfect, Good, Miss }
|
||||
public enum Result { Perfect, Good, Bad, Miss }
|
||||
public class RhythmManager : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private AudioSource _audioSource;
|
||||
[SerializeField] private RhythmChart _currentChart;
|
||||
[SerializeField] private RhythmNoteSpawner _spawner;
|
||||
[SerializeField] private float _leadTime = 2f; // 노트가 생성돼 판정선까지 흐르는 시간(초)
|
||||
public float SongTime => _audioSource.time; // 모든 타이밍의 기준
|
||||
[SerializeField] private float _leadTime = 1f; // 노트가 생성돼 판정선까지 흐르는 시간(초)
|
||||
|
||||
[Header("판정 윈도우 (초, 절대 시간차 기준)")]
|
||||
[SerializeField] private float _perfectWindow = 0.05f;
|
||||
[SerializeField] private float _goodWindow = 0.10f;
|
||||
[SerializeField] private float _badWindow = 0.15f; // 이 밖이면 입력 무시(헛침)
|
||||
|
||||
[Header("효과음")]
|
||||
[SerializeField] private AudioClip _hitSfx; // 내려칠 때마다 재생 (판정과 무관, 헛침 포함)
|
||||
|
||||
[Header("오토플레이 (아이템)")]
|
||||
[SerializeField] private bool _autoPlay; // 켜지면 모든 노트를 판정선 도달 순간 자동 Perfect 처리
|
||||
[SerializeField] private RhythmStick[] _autoPlaySticks; // 인덱스 = Note.Lane. 오토플레이 시 자동으로 휘두를 스틱들
|
||||
|
||||
[SerializeField] private GameObject StartButtonObj;
|
||||
|
||||
// 모든 타이밍의 기준. 오디오 클럭(dspTime) 기반이라 리드인 동안 음수(-leadTime→0)로 흐른다
|
||||
public float SongTime => (float)(AudioSettings.dspTime - _dspSongStart);
|
||||
|
||||
private List<Note> SongNoteList;
|
||||
private int _nextNoteIndex; // 다음에 소환할 노트 인덱스
|
||||
private bool _isPlaying; //곡이 재생 중인지 (종료 감지용)
|
||||
private double _dspSongStart; // 오디오가 실제 시작되는 dsp 시각 (= SongTime 0 지점)
|
||||
private float _clipLength; // 곡 길이 캐시 (종료 감지용)
|
||||
private Func<float> _songTimeProvider; // 노트에 넘길 시간 제공자 (매 스폰마다 람다 재생성 방지용 캐시)
|
||||
private readonly List<RhythmNoteInstance> _activeNotes = new(); // 화면에 떠 있는(아직 처리 안 된) 노트들
|
||||
|
||||
// 점수 상태 (HUD가 읽음). 누적 로직은 RhythmScore가 전담
|
||||
public RhythmScore Score { get; } = new();
|
||||
|
||||
public event Action OnSongStarted; // 곡 시작 (ScoreHud 표시 / ResultHud 숨김)
|
||||
public event Action<Result> OnJudged; // 노트 하나가 판정될 때마다 (HUD 연출용)
|
||||
public event Action<RhythmScore> OnScoreChanged; // 점수/콤보가 바뀔 때 (실시간 HUD)
|
||||
public event Action<RhythmScore> OnSongFinished; // 곡이 끝났을 때 (결과창)
|
||||
|
||||
private RhythmCat[] _rhythmCats;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
@@ -24,18 +54,78 @@ private void Awake()
|
||||
private void Start()
|
||||
{
|
||||
ChangeSong();
|
||||
|
||||
InputManager.Instance.OnKey_Left_Event += OnPlayerInput;
|
||||
InputManager.Instance.OnKey_Right_Event += OnPlayerInput;
|
||||
|
||||
_rhythmCats = FindObjectsByType<RhythmCat>(FindObjectsSortMode.None);
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
//재생 중이던 곡이 끝나면 주변음 복구
|
||||
if (_isPlaying && !_audioSource.isPlaying)
|
||||
if (!_isPlaying) return;
|
||||
|
||||
//곡이 끝나면(리드인 지나 곡 길이 도달) 정지·주변음 복구
|
||||
if (SongTime >= _clipLength)
|
||||
{
|
||||
StopSong();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isPlaying) SpawnDueNotes();
|
||||
SpawnDueNotes();
|
||||
|
||||
if (_autoPlay)
|
||||
{
|
||||
DriveAutoPlaySticks(); // 다가오는 노트에 맞춰 스틱을 들었다 내림 (판정 전에 호출)
|
||||
AutoHitDueNotes(); // 판정선 도달한 노트 자동 Perfect
|
||||
}
|
||||
}
|
||||
|
||||
// 오토플레이: 레인별로 가장 임박한 노트에 맞춰 스틱을 휘두름
|
||||
private void DriveAutoPlaySticks()
|
||||
{
|
||||
if (_autoPlaySticks == null) return;
|
||||
|
||||
for (int lane = 0; lane < _autoPlaySticks.Length; lane++)
|
||||
{
|
||||
RhythmStick stick = _autoPlaySticks[lane];
|
||||
if (stick == null) continue;
|
||||
|
||||
// 이 레인에서 아직 안 친(미래의) 노트 중 가장 가까운 타격 시각
|
||||
float nextHit = float.PositiveInfinity;
|
||||
foreach (RhythmNoteInstance note in _activeNotes)
|
||||
{
|
||||
int noteLane = Mathf.Clamp(note.Lane, 0, _autoPlaySticks.Length - 1);
|
||||
if (noteLane != lane) continue;
|
||||
if (note.HitTime < SongTime) continue; // 이미 지난(곧 처리될) 노트 제외
|
||||
if (note.HitTime < nextHit) nextHit = note.HitTime;
|
||||
}
|
||||
|
||||
stick.Drive(nextHit - SongTime); // 다가오는 노트 없으면 +∞ → 대기 자세
|
||||
}
|
||||
}
|
||||
|
||||
// 오토플레이: 판정선에 도달한(HitTime을 지난) 노트를 자동으로 Perfect 처리
|
||||
private void AutoHitDueNotes()
|
||||
{
|
||||
// 뒤에서부터 순회해야 처리한 노트를 안전하게 리스트에서 제거할 수 있다
|
||||
for (int i = _activeNotes.Count - 1; i >= 0; i--)
|
||||
{
|
||||
RhythmNoteInstance note = _activeNotes[i];
|
||||
if (SongTime < note.HitTime) continue; // 아직 판정선 도달 전이면 건너뜀
|
||||
|
||||
// 수동 입력과 동일한 연출: 내려치는 효과음 + 히트 이펙트
|
||||
if (_hitSfx != null && SoundManager.Instance != null)
|
||||
SoundManager.Instance.PlaySFX(_hitSfx);
|
||||
|
||||
if (note.TryGetComponent(out RhythmProjectile projectile))
|
||||
projectile.Detonate();
|
||||
|
||||
_activeNotes.RemoveAt(i);
|
||||
Destroy(note.gameObject);
|
||||
|
||||
ApplyJudge(Result.Perfect); // 시간차 ≈ 0 → 항상 Perfect
|
||||
}
|
||||
}
|
||||
|
||||
// SongTime 기준으로 소환할 때가 된 노트들을 순서대로 생성
|
||||
@@ -47,7 +137,8 @@ private void SpawnDueNotes()
|
||||
{
|
||||
Note note = SongNoteList[_nextNoteIndex];
|
||||
// spawnTime은 실제 프레임 시각이 아니라 이론값(note.Time - _leadTime)으로 줘야 보간이 정확
|
||||
_spawner.SpawnNote(note, note.Time - _leadTime, _songTimeProvider, OnNoteMissed);
|
||||
RhythmNoteInstance instance = _spawner.SpawnNote(note, note.Time - _leadTime, _songTimeProvider, OnNoteMissed);
|
||||
_activeNotes.Add(instance);
|
||||
_nextNoteIndex++;
|
||||
}
|
||||
}
|
||||
@@ -57,41 +148,125 @@ public void ChangeSong()
|
||||
_audioSource.clip = _currentChart.SongClip;
|
||||
}
|
||||
|
||||
// 오토플레이 아이템 사용: 곡 시작 전(또는 도중)에 호출하면 남은 노트가 전부 자동 Perfect
|
||||
public void EnableAutoPlay() => _autoPlay = true;
|
||||
|
||||
// 곡 재생 + 채보 로드
|
||||
public void StartSong()
|
||||
{
|
||||
SongNoteList = _currentChart.GenerateNotes();
|
||||
_nextNoteIndex = 0;
|
||||
_activeNotes.Clear();
|
||||
|
||||
Score.Reset();
|
||||
OnSongStarted?.Invoke(); // ScoreHud 표시 / ResultHud 숨김
|
||||
OnScoreChanged?.Invoke(Score); // HUD 초기화(0점)
|
||||
StartButtonObj.SetActive(false);
|
||||
|
||||
_clipLength = _audioSource.clip != null ? _audioSource.clip.length : 0f;
|
||||
|
||||
// 리드인: 지금부터 _leadTime 뒤에 오디오 시작.
|
||||
// 그 사이 SongTime이 -_leadTime→0으로 흘러 첫 노트(0초 부근)도 충분히 날아온다
|
||||
_dspSongStart = AudioSettings.dspTime + _leadTime;
|
||||
_audioSource.PlayScheduled(_dspSongStart);
|
||||
|
||||
_audioSource.Play();
|
||||
_isPlaying = true;
|
||||
|
||||
//BGM·환경음을 낮춰 리듬게임 소리만 들리게
|
||||
if (SoundManager.Instance != null) SoundManager.Instance.EnterMinigameMode();
|
||||
|
||||
for(int i=0;i<_rhythmCats.Length;i++)
|
||||
{
|
||||
_rhythmCats[i].Dance(i*1f);
|
||||
}
|
||||
}
|
||||
|
||||
// 곡 정지 + 주변음 복구
|
||||
public void StopSong()
|
||||
{
|
||||
if (!_isPlaying) return; // 중복 호출 방지(결과창 두 번 뜨는 것 차단)
|
||||
|
||||
_audioSource.Stop();
|
||||
_isPlaying = false;
|
||||
|
||||
if (_autoPlay && _autoPlaySticks != null) // 곡 끝나면 들려있던 스틱 대기 자세로 복귀
|
||||
foreach (RhythmStick stick in _autoPlaySticks)
|
||||
if (stick != null) stick.ResetPose();
|
||||
|
||||
_autoPlay = false; // 아이템 효과는 한 곡만 — 곡이 끝나면 자동 해제
|
||||
|
||||
if (SoundManager.Instance != null) SoundManager.Instance.ExitMinigameMode();
|
||||
|
||||
OnSongFinished?.Invoke(Score); // 결과창 표시
|
||||
|
||||
for(int i=0;i<_rhythmCats.Length;i++)
|
||||
{
|
||||
_rhythmCats[i].DanceStop();
|
||||
}
|
||||
}
|
||||
|
||||
// 노트가 판정선을 지나쳐 스스로 Miss 처리될 때 호출
|
||||
// 노트가 판정선을 지나쳐 스스로 Miss 처리될 때 호출 (노트는 이미 자기 파괴됨)
|
||||
private void OnNoteMissed(RhythmNoteInstance note)
|
||||
{
|
||||
// TODO: 점수/콤보 시스템 연결 시 Miss 반영
|
||||
_activeNotes.Remove(note);
|
||||
ApplyJudge(Result.Miss);
|
||||
Debug.Log("[Rhythm] Miss (지나침)");
|
||||
}
|
||||
|
||||
public void OnPlayerInput()
|
||||
{
|
||||
if (!_isPlaying) return;
|
||||
|
||||
// 판정과 무관하게 내려칠 때마다 효과음 (헛침 포함)
|
||||
if (_hitSfx != null && SoundManager.Instance != null)
|
||||
SoundManager.Instance.PlaySFX(_hitSfx);
|
||||
|
||||
if (_activeNotes.Count == 0) return;
|
||||
|
||||
// 판정선에 가장 가까운(시간차 최소) 노트 탐색
|
||||
RhythmNoteInstance target = null;
|
||||
float bestDiff = float.MaxValue;
|
||||
foreach (RhythmNoteInstance note in _activeNotes)
|
||||
{
|
||||
float diff = Mathf.Abs(SongTime - note.HitTime);
|
||||
if (diff < bestDiff)
|
||||
{
|
||||
bestDiff = diff;
|
||||
target = note;
|
||||
}
|
||||
}
|
||||
|
||||
Result result = Judge(bestDiff);
|
||||
if (result == Result.Miss) return; // 히트 범위 밖이면 입력 무시 (노트 소비 안 함)
|
||||
|
||||
// 성공 판정 → 히트 이펙트 + 노트 소비
|
||||
if (result == Result.Perfect || result == Result.Good)
|
||||
{
|
||||
// 이펙트는 노트에서 분리돼 따로 재생되므로 노트를 바로 파괴해도 됨
|
||||
if (target.TryGetComponent(out RhythmProjectile projectile))
|
||||
projectile.Detonate();
|
||||
}
|
||||
_activeNotes.Remove(target);
|
||||
Destroy(target.gameObject);
|
||||
|
||||
ApplyJudge(result);
|
||||
Debug.Log($"[Rhythm] {result} (diff {bestDiff:F3}s)");
|
||||
}
|
||||
|
||||
// 판정 결과를 점수에 반영하고 이벤트로 알림
|
||||
private void ApplyJudge(Result result)
|
||||
{
|
||||
Score.Apply(result);
|
||||
OnJudged?.Invoke(result);
|
||||
OnScoreChanged?.Invoke(Score);
|
||||
}
|
||||
|
||||
// diff = 절대 시간차(초). 윈도우 안이면 Perfect/Good/Bad, 밖이면 Miss(입력 무시)
|
||||
public Result Judge(float diff)
|
||||
{
|
||||
if (diff <= _perfectWindow) return Result.Perfect;
|
||||
if (diff <= _goodWindow) return Result.Good;
|
||||
if (diff <= _badWindow) return Result.Bad;
|
||||
return Result.Miss;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ public class InputManager : MonoBehaviour, GameInput.IPlayerActions
|
||||
|
||||
// ─── 입력 이벤트들 (PlayerController 등이 구독) ──────────────────────
|
||||
public event Action OnJump_Event; // 한 번씩 (눌렀을 때)
|
||||
|
||||
//키보드로 테스트용
|
||||
public event Action OnKey_Left_Event;
|
||||
public event Action OnKey_Right_Event;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
@@ -39,4 +43,16 @@ public void OnJump(InputAction.CallbackContext ctx)
|
||||
if (ctx.phase == InputActionPhase.Started)
|
||||
OnJump_Event?.Invoke();
|
||||
}
|
||||
|
||||
public void OnKey_Left(InputAction.CallbackContext ctx)
|
||||
{
|
||||
if (ctx.phase == InputActionPhase.Started)
|
||||
OnKey_Left_Event?.Invoke();
|
||||
}
|
||||
|
||||
public void OnKey_Right(InputAction.CallbackContext ctx)
|
||||
{
|
||||
if (ctx.phase == InputActionPhase.Started)
|
||||
OnKey_Right_Event?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
public class RoomMoveButton : MonoBehaviour
|
||||
{
|
||||
[Header("이 버튼을 눌렀을 때 이동할 방 번호 입력")]
|
||||
[Header("이 버튼을 눌렀을 때 이동할 방 번호 입력")]
|
||||
[SerializeField] private int targetRoomNumber;
|
||||
|
||||
public void OnClickMoveRoom()
|
||||
{
|
||||
if (RoomRouteManager.Instance == null)
|
||||
{
|
||||
Debug.LogError("RoomRouteManager가 없습니다.");
|
||||
Debug.LogError("RoomRouteManager가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,50 +19,50 @@ private void Update()
|
||||
return;
|
||||
}
|
||||
|
||||
// C키: 방문 안 한 방 중 랜덤 후보 뽑기
|
||||
// C키: 방문 안 한 방 중 랜덤 후보 뽑기
|
||||
if (Keyboard.current.cKey.wasPressedThisFrame)
|
||||
{
|
||||
currentChoices = RoomRouteManager.Instance.GetRandomNextRooms();
|
||||
|
||||
Debug.Log($"현재 선택 가능한 후보 개수: {currentChoices.Count}");
|
||||
Debug.Log($"현재 선택 가능한 후보 개수: {currentChoices.Count}");
|
||||
|
||||
for (int i = 0; i < currentChoices.Count; i++)
|
||||
{
|
||||
Debug.Log($"{i + 1}번 선택지 → 방 번호: {currentChoices[i].roomNumber}, 씬 이름: {currentChoices[i].sceneName}");
|
||||
Debug.Log($"{i + 1}번 선택지 → 방 번호: {currentChoices[i].roomNumber}, 씬 이름: {currentChoices[i].sceneName}");
|
||||
}
|
||||
}
|
||||
|
||||
// 1키: 첫 번째 후보 선택
|
||||
// 1키: 첫 번째 후보 선택
|
||||
if (Keyboard.current.digit1Key.wasPressedThisFrame)
|
||||
{
|
||||
MoveToChoice(0);
|
||||
}
|
||||
|
||||
// 2키: 두 번째 후보 선택
|
||||
// 2키: 두 번째 후보 선택
|
||||
if (Keyboard.current.digit2Key.wasPressedThisFrame)
|
||||
{
|
||||
MoveToChoice(1);
|
||||
}
|
||||
|
||||
// T키: 방문 상태 확인
|
||||
// T키: 방문 상태 확인
|
||||
if (Keyboard.current.tKey.wasPressedThisFrame)
|
||||
{
|
||||
Debug.Log($"방문한 방 개수: {RoomRouteManager.Instance.VisitedRoomCount} / {RoomRouteManager.Instance.TotalRoomCount}");
|
||||
Debug.Log($"현재 방 번호: {RoomRouteManager.Instance.CurrentRoomNumber}");
|
||||
Debug.Log($"방문한 방 개수: {RoomRouteManager.Instance.VisitedRoomCount} / {RoomRouteManager.Instance.TotalRoomCount}");
|
||||
Debug.Log($"현재 방 번호: {RoomRouteManager.Instance.CurrentRoomNumber}");
|
||||
}
|
||||
|
||||
// X키: 테스트용 방문 기록 초기화
|
||||
// X키: 테스트용 방문 기록 초기화
|
||||
if (Keyboard.current.xKey.wasPressedThisFrame)
|
||||
{
|
||||
Debug.Log("방문 기록 초기화");
|
||||
Debug.Log("방문 기록 초기화");
|
||||
currentChoices.Clear();
|
||||
RoomRouteManager.Instance.ResetVisitedRooms();
|
||||
}
|
||||
|
||||
// F키: 모든 방 방문 후 마지막 씬 이동 테스트
|
||||
// F키: 모든 방 방문 후 마지막 씬 이동 테스트
|
||||
if (Keyboard.current.fKey.wasPressedThisFrame)
|
||||
{
|
||||
Debug.Log("마지막 씬 이동 테스트");
|
||||
Debug.Log("마지막 씬 이동 테스트");
|
||||
RoomRouteManager.Instance.MoveToFinalScene();
|
||||
}
|
||||
}
|
||||
@@ -71,19 +71,19 @@ private void MoveToChoice(int index)
|
||||
{
|
||||
if (currentChoices == null || currentChoices.Count == 0)
|
||||
{
|
||||
Debug.LogWarning("먼저 C키를 눌러 랜덤 후보를 뽑아야 합니다.");
|
||||
Debug.LogWarning("먼저 C키를 눌러 랜덤 후보를 뽑아야 합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (index < 0 || index >= currentChoices.Count)
|
||||
{
|
||||
Debug.LogWarning("해당 번호의 선택지가 없습니다.");
|
||||
Debug.LogWarning("해당 번호의 선택지가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
int targetRoomNumber = currentChoices[index].roomNumber;
|
||||
|
||||
Debug.Log($"{index + 1}번 선택지 선택 → 방 {targetRoomNumber} 이동");
|
||||
Debug.Log($"{index + 1}번 선택지 선택 → 방 {targetRoomNumber} 이동");
|
||||
|
||||
currentChoices.Clear();
|
||||
|
||||
|
||||
@@ -8,23 +8,23 @@ public class RoomRouteManager : MonoBehaviour
|
||||
[System.Serializable]
|
||||
public class RoomData
|
||||
{
|
||||
[Header("방 번호 입력")]
|
||||
[Header("방 번호 입력")]
|
||||
public int roomNumber;
|
||||
|
||||
[Header("이 방에 해당하는 Scene 이름 입력")]
|
||||
[Header("이 방에 해당하는 Scene 이름 입력")]
|
||||
public string sceneName;
|
||||
}
|
||||
|
||||
[Header("전체 방 정보 입력")]
|
||||
[Header("전체 방 정보 입력")]
|
||||
[SerializeField] private List<RoomData> rooms = new List<RoomData>();
|
||||
|
||||
[Header("시작 방 번호 입력")]
|
||||
[Header("시작 방 번호 입력")]
|
||||
[SerializeField] private int startRoomNumber;
|
||||
|
||||
[Header("랜덤 선택지 개수")]
|
||||
[Header("랜덤 선택지 개수")]
|
||||
[SerializeField] private int randomChoiceCount = 2;
|
||||
|
||||
[Header("모든 방 방문 후 이동할 마지막 Scene 이름")]
|
||||
[Header("모든 방 방문 후 이동할 마지막 Scene 이름")]
|
||||
[SerializeField] private string finalSceneName;
|
||||
|
||||
private int _currentRoomNumber;
|
||||
@@ -54,7 +54,7 @@ private void Awake()
|
||||
}
|
||||
}
|
||||
|
||||
// 방문하지 않은 방 전체 반환
|
||||
// 방문하지 않은 방 전체 반환
|
||||
public List<RoomData> GetUnvisitedRooms()
|
||||
{
|
||||
List<RoomData> result = new List<RoomData>();
|
||||
@@ -70,7 +70,7 @@ public List<RoomData> GetUnvisitedRooms()
|
||||
return result;
|
||||
}
|
||||
|
||||
// 대화 선택지에 보여줄 랜덤 방 목록 반환
|
||||
// 대화 선택지에 보여줄 랜덤 방 목록 반환
|
||||
public List<RoomData> GetRandomNextRooms()
|
||||
{
|
||||
List<RoomData> unvisitedRooms = GetUnvisitedRooms();
|
||||
@@ -89,18 +89,18 @@ public List<RoomData> GetRandomNextRooms()
|
||||
return randomRooms;
|
||||
}
|
||||
|
||||
// 버튼이나 대화 선택지에서 호출
|
||||
// 버튼이나 대화 선택지에서 호출
|
||||
public void MoveToRoom(int roomNumber)
|
||||
{
|
||||
if (SceneLoadManager.Instance == null)
|
||||
{
|
||||
Debug.LogError("SceneLoadManager가 씬에 없습니다.");
|
||||
Debug.LogError("SceneLoadManager가 씬에 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (SceneLoadManager.Instance.IsChangingScene)
|
||||
{
|
||||
Debug.Log("이미 씬 이동 중입니다.");
|
||||
Debug.Log("이미 씬 이동 중입니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -108,19 +108,19 @@ public void MoveToRoom(int roomNumber)
|
||||
|
||||
if (targetRoom == null)
|
||||
{
|
||||
Debug.LogWarning($"방 정보를 찾을 수 없습니다. 방 번호: {roomNumber}");
|
||||
Debug.LogWarning($"방 정보를 찾을 수 없습니다. 방 번호: {roomNumber}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_visitedRooms.Contains(roomNumber))
|
||||
{
|
||||
Debug.Log($"이미 방문한 방입니다. 방 번호: {roomNumber}");
|
||||
Debug.Log($"이미 방문한 방입니다. 방 번호: {roomNumber}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(targetRoom.sceneName))
|
||||
{
|
||||
Debug.LogWarning($"방 {roomNumber}의 Scene 이름이 비어있습니다.");
|
||||
Debug.LogWarning($"방 {roomNumber}의 Scene 이름이 비어있습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -130,14 +130,14 @@ public void MoveToRoom(int roomNumber)
|
||||
SceneLoadManager.Instance.RequestSceneChange(targetRoom.sceneName);
|
||||
}
|
||||
|
||||
// 랜덤 방 하나로 바로 이동하고 싶을 때 사용
|
||||
// 랜덤 방 하나로 바로 이동하고 싶을 때 사용
|
||||
public void MoveToRandomRoom()
|
||||
{
|
||||
List<RoomData> unvisitedRooms = GetUnvisitedRooms();
|
||||
|
||||
if (unvisitedRooms.Count <= 0)
|
||||
{
|
||||
Debug.Log("방문하지 않은 방이 없습니다.");
|
||||
Debug.Log("방문하지 않은 방이 없습니다.");
|
||||
|
||||
if (IsAllRoomsVisited())
|
||||
{
|
||||
@@ -162,25 +162,25 @@ public void MoveToFinalScene()
|
||||
{
|
||||
if (!IsAllRoomsVisited())
|
||||
{
|
||||
Debug.Log("아직 모든 방을 방문하지 않았습니다.");
|
||||
Debug.Log("아직 모든 방을 방문하지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (SceneLoadManager.Instance == null)
|
||||
{
|
||||
Debug.LogError("SceneLoadManager가 씬에 없습니다.");
|
||||
Debug.LogError("SceneLoadManager가 씬에 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (SceneLoadManager.Instance.IsChangingScene)
|
||||
{
|
||||
Debug.Log("이미 씬 이동 중입니다.");
|
||||
Debug.Log("이미 씬 이동 중입니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(finalSceneName))
|
||||
{
|
||||
Debug.LogWarning("마지막 Scene 이름이 비어있습니다.");
|
||||
Debug.LogWarning("마지막 Scene 이름이 비어있습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
8
Assets/02_Scripts/Npcs.meta
Normal file
8
Assets/02_Scripts/Npcs.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b32bc9c54d149494f80435205fe281ea
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
21
Assets/02_Scripts/Npcs/RhythmCat.cs
Normal file
21
Assets/02_Scripts/Npcs/RhythmCat.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using UnityEngine;
|
||||
|
||||
public class RhythmCat : MonoBehaviour
|
||||
{
|
||||
Animator anim;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
anim = GetComponent<Animator>();
|
||||
}
|
||||
|
||||
public void Dance(float delay)
|
||||
{
|
||||
_ = Util.RunDelayed(delay,()=>{anim.SetBool("Dance",true);},this.destroyCancellationToken);
|
||||
}
|
||||
|
||||
public void DanceStop()
|
||||
{
|
||||
anim.SetBool("Dance",false);
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Npcs/RhythmCat.cs.meta
Normal file
2
Assets/02_Scripts/Npcs/RhythmCat.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c9cd0b46988c9bf4f927ad5d9cb2f5ed
|
||||
@@ -3,7 +3,8 @@
|
||||
|
||||
public class RhythmNoteInstance : MonoBehaviour
|
||||
{
|
||||
public float HitTime; // 이 노트의 도달 시각
|
||||
[HideInInspector] public float HitTime; // 이 노트의 도달 시각
|
||||
[HideInInspector] public int Lane; // 이 노트의 레인 (오토플레이 스틱 매칭용)
|
||||
|
||||
[SerializeField] private float _missWindow = 0.15f; // 판정선을 이만큼 지나면 자동 Miss
|
||||
|
||||
@@ -14,13 +15,14 @@ public class RhythmNoteInstance : MonoBehaviour
|
||||
private Action<RhythmNoteInstance> _onMiss; // 지나쳐서 Miss 났을 때 통지
|
||||
|
||||
// 스폰 시 목적지·타이밍 주입 (B 방식)
|
||||
public void Setup(Vector3 start, Vector3 target, float spawnTime, float hitTime,
|
||||
public void Setup(Vector3 start, Vector3 target, float spawnTime, float hitTime, int lane,
|
||||
Func<float> songTime, Action<RhythmNoteInstance> onMiss = null)
|
||||
{
|
||||
_start = start;
|
||||
_target = target;
|
||||
_spawnTime = spawnTime;
|
||||
HitTime = hitTime;
|
||||
Lane = lane;
|
||||
_songTime = songTime;
|
||||
_onMiss = onMiss;
|
||||
|
||||
|
||||
@@ -3,17 +3,38 @@
|
||||
|
||||
public class RhythmNoteSpawner : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private RhythmNoteInstance _notePrefab; // 생성할 노트 프리팹
|
||||
// 레인(손)별 설정 - 전용 프리팹 + 가로 위치 오프셋
|
||||
[Serializable]
|
||||
public class LaneVisual
|
||||
{
|
||||
public RhythmNoteInstance Prefab; // 이 레인 전용 노트 프리팹 (왼손/오른손 다른 모양)
|
||||
public Vector3 Offset; // 스폰/판정 위치 가로 오프셋 (왼손 -x, 오른손 +x 등)
|
||||
}
|
||||
|
||||
[SerializeField] private RhythmNoteInstance _notePrefab; // 기본 프리팹 (레인 전용 미지정 시 사용)
|
||||
[SerializeField] private Transform _spawnPoint; // 노트가 생겨나는 위치(미지정 시 자기 위치)
|
||||
[SerializeField] private Transform _judgmentLine; // 목적지(판정선)
|
||||
[SerializeField] private LaneVisual[] _lanes; // 인덱스 = Note.Lane (RhythmChart.LanePitches 순서와 일치)
|
||||
|
||||
// 노트 프리팹 생성, 목적지·타이밍 주입
|
||||
// 노트 생성, 목적지·타이밍 주입. 레인에 따라 프리팹/위치만 다르게(판정은 동일)
|
||||
public RhythmNoteInstance SpawnNote(Note note, float spawnTime,
|
||||
Func<float> songTime, Action<RhythmNoteInstance> onMiss = null)
|
||||
{
|
||||
Transform origin = _spawnPoint != null ? _spawnPoint : transform;
|
||||
RhythmNoteInstance instance = Instantiate(_notePrefab, origin.position, origin.rotation, transform);
|
||||
instance.Setup(origin.position, _judgmentLine.position, spawnTime, note.Time, songTime, onMiss);
|
||||
|
||||
// 레인 범위 밖이면 기본 프리팹/오프셋 0
|
||||
LaneVisual lane = (_lanes != null && note.Lane >= 0 && note.Lane < _lanes.Length)
|
||||
? _lanes[note.Lane] : null;
|
||||
|
||||
RhythmNoteInstance prefab = (lane != null && lane.Prefab != null) ? lane.Prefab : _notePrefab;
|
||||
|
||||
// 오프셋을 origin 방향 기준으로 적용해 레인을 평행 이동(start·target 동일 오프셋이라 경로가 곧음)
|
||||
Vector3 worldOffset = lane != null ? origin.rotation * lane.Offset : Vector3.zero;
|
||||
Vector3 start = origin.position + worldOffset;
|
||||
Vector3 target = _judgmentLine.position + worldOffset;
|
||||
|
||||
RhythmNoteInstance instance = Instantiate(prefab, start, origin.rotation, transform);
|
||||
instance.Setup(start, target, spawnTime, note.Time, note.Lane, songTime, onMiss);
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
37
Assets/02_Scripts/Rhythm/RhythmProjectile.cs
Normal file
37
Assets/02_Scripts/Rhythm/RhythmProjectile.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using Hovl;
|
||||
using UnityEngine;
|
||||
|
||||
// Hovl의 HS_ProjectileMover(에셋)를 "수정하지 않고" 히트 이펙트를 코드로 터뜨리기 위한 서브클래스.
|
||||
// 핵심: protected 멤버(hit/hitPS/projectilePS/collided)는 '파생 클래스'에서는 접근 가능하다.
|
||||
// 그래서 에셋 원본은 그대로 두고, 충돌(Collision) 없이 판정 성공 시 Detonate()로 이펙트만 재생한다.
|
||||
public class RhythmProjectile : HS_ProjectileMover
|
||||
{
|
||||
// 판정 성공 시 호출. 노트가 곧 Destroy되어도 이펙트가 보이도록 노트에서 분리 후 재생.
|
||||
public void Detonate()
|
||||
{
|
||||
if (collided) return;
|
||||
collided = true;
|
||||
|
||||
// 날아가는 동안의 트레일 파티클은 정지
|
||||
if (projectilePS != null)
|
||||
projectilePS.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
|
||||
|
||||
// 히트 이펙트 컨테이너(없으면 파티클 본체)를 노트에서 떼어내 현재 위치에서 재생
|
||||
GameObject fx = hit != null ? hit : (hitPS != null ? hitPS.gameObject : null);
|
||||
if (fx == null) return;
|
||||
|
||||
fx.transform.SetParent(null, true);
|
||||
fx.transform.position = transform.position;
|
||||
fx.SetActive(true);
|
||||
|
||||
if (hitPS != null)
|
||||
{
|
||||
hitPS.Clear(true);
|
||||
hitPS.Play(true);
|
||||
}
|
||||
|
||||
// 재생이 끝나면 분리한 이펙트도 정리
|
||||
float life = hitPS != null ? hitPS.main.duration + hitPS.main.startLifetime.constantMax : 2f;
|
||||
Destroy(fx, life);
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Rhythm/RhythmProjectile.cs.meta
Normal file
2
Assets/02_Scripts/Rhythm/RhythmProjectile.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9840334459414e240b44e1b0cfe4ebb2
|
||||
59
Assets/02_Scripts/Rhythm/RhythmScore.cs
Normal file
59
Assets/02_Scripts/Rhythm/RhythmScore.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
// 리듬게임 점수/콤보/판정수 누적 (Unity 비의존 순수 로직 - 테스트하기 쉬움)
|
||||
public class RhythmScore
|
||||
{
|
||||
// 판정별 기본 점수
|
||||
private const int PerfectPoint = 100;
|
||||
private const int GoodPoint = 50;
|
||||
private const int BadPoint = 10;
|
||||
|
||||
public int Score { get; private set; }
|
||||
public int Combo { get; private set; }
|
||||
public int MaxCombo { get; private set; }
|
||||
|
||||
public int PerfectCount { get; private set; }
|
||||
public int GoodCount { get; private set; }
|
||||
public int BadCount { get; private set; }
|
||||
public int MissCount { get; private set; }
|
||||
|
||||
public int TotalJudged => PerfectCount + GoodCount + BadCount + MissCount;
|
||||
|
||||
// 판정 하나를 점수에 반영
|
||||
public void Apply(Result result)
|
||||
{
|
||||
switch (result)
|
||||
{
|
||||
case Result.Perfect:
|
||||
PerfectCount++;
|
||||
Combo++;
|
||||
Score += PerfectPoint + Combo; // 콤보가 쌓일수록 보너스 (연속 보상)
|
||||
break;
|
||||
case Result.Good:
|
||||
GoodCount++;
|
||||
Combo++;
|
||||
Score += GoodPoint + Combo;
|
||||
break;
|
||||
case Result.Bad:
|
||||
BadCount++;
|
||||
Combo = 0; // 콤보 끊김
|
||||
Score += BadPoint;
|
||||
break;
|
||||
case Result.Miss:
|
||||
MissCount++;
|
||||
Combo = 0; // 콤보 끊김
|
||||
break;
|
||||
}
|
||||
|
||||
if (Combo > MaxCombo) MaxCombo = Combo;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
Score = 0;
|
||||
Combo = 0;
|
||||
MaxCombo = 0;
|
||||
PerfectCount = 0;
|
||||
GoodCount = 0;
|
||||
BadCount = 0;
|
||||
MissCount = 0;
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Rhythm/RhythmScore.cs.meta
Normal file
2
Assets/02_Scripts/Rhythm/RhythmScore.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: adfd55d5980f58940ad4cb735db42f89
|
||||
99
Assets/02_Scripts/Rhythm/RhythmStick.cs
Normal file
99
Assets/02_Scripts/Rhythm/RhythmStick.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using UnityEngine;
|
||||
|
||||
// 오토플레이용 스틱 포즈 드라이버.
|
||||
// 매니저가 매 프레임 Drive(dt)를 호출하면 dt(타격까지 남은 시간)에 따라 회전을 세팅한다.
|
||||
// 모션: 대기 → (윈드업) 위로 들기 → (스트라이크) 내려치며 대기 자세보다 _strikeOvershoot 만큼 더 깊이 →
|
||||
// (팔로스루) 대기 자세로 부드럽게 복귀.
|
||||
// 노트는 타격 순간 파괴돼 그 뒤 dt가 끊기므로, 팔로스루만 Time.deltaTime으로 스틱이 자체 처리한다.
|
||||
public class RhythmStick : MonoBehaviour
|
||||
{
|
||||
[Tooltip("들어올릴 때 회전할 로컬 축 (예: 손목 꺾이는 축)")]
|
||||
[SerializeField] private Vector3 _raiseAxis = Vector3.right;
|
||||
|
||||
[Tooltip("대기 자세에서 위로 들어올리는 각도(도)")]
|
||||
[SerializeField] private float _raiseAngle = 50f;
|
||||
|
||||
[Tooltip("타격 시 대기 자세보다 더 깊이 내려가는 각도(도)")]
|
||||
[SerializeField] private float _strikeOvershoot = 10f;
|
||||
|
||||
[Tooltip("타격 몇 초 전부터 들어올리기 시작하는지")]
|
||||
[SerializeField] private float _windupTime = 0.1f;
|
||||
|
||||
[Tooltip("내려치는 데 걸리는 시간(초). _windupTime 보다 작아야 한다")]
|
||||
[SerializeField] private float _strikeTime = 0.04f;
|
||||
|
||||
[Tooltip("타격 후 대기 자세로 되돌아오는 시간(초)")]
|
||||
[SerializeField] private float _recoverTime = 0.08f;
|
||||
|
||||
private Quaternion _restRot; // 대기 자세
|
||||
private Quaternion _raisedRot; // 들어올린 자세
|
||||
private Quaternion _overshootRot; // 타격 시 더 깊이 내려간 자세
|
||||
|
||||
private bool _armed; // 내려치는 중 → 다음 프레임 타격 예정
|
||||
private bool _recovering; // 타격 후 복귀 중
|
||||
private float _recoverElapsed;
|
||||
private Quaternion _recoverFrom; // 복귀 시작 회전(스냅 방지)
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_restRot = transform.localRotation;
|
||||
Vector3 axis = _raiseAxis.normalized;
|
||||
_raisedRot = _restRot * Quaternion.AngleAxis(_raiseAngle, axis);
|
||||
_overshootRot = _restRot * Quaternion.AngleAxis(-_strikeOvershoot, axis); // 반대 방향 = 더 내려감
|
||||
}
|
||||
|
||||
// dt = 다음 타격까지 남은 시간(초). 다가오는 노트가 없으면 +∞.
|
||||
public void Drive(float dt)
|
||||
{
|
||||
// 1) 임박한 스윙(윈드업~타격)이 최우선
|
||||
if (dt > 0f && dt <= _windupTime)
|
||||
{
|
||||
_recovering = false;
|
||||
_armed = true; // 곧 타격함
|
||||
|
||||
if (dt > _strikeTime)
|
||||
{
|
||||
// 들어올리는 구간: dt가 _windupTime→_strikeTime 으로 줄며 rest→raised
|
||||
float u = Mathf.InverseLerp(_windupTime, _strikeTime, dt);
|
||||
transform.localRotation = Quaternion.Slerp(_restRot, _raisedRot, Mathf.SmoothStep(0f, 1f, u));
|
||||
}
|
||||
else
|
||||
{
|
||||
// 내려치는 구간: dt가 _strikeTime→0 으로 줄며 raised→overshoot (dt=0에 가장 깊이)
|
||||
float s = Mathf.InverseLerp(_strikeTime, 0f, dt);
|
||||
transform.localRotation = Quaternion.Slerp(_raisedRot, _overshootRot, Mathf.SmoothStep(0f, 1f, s));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) 방금 타격이 끝났다면(노트 파괴로 dt가 끊김) 팔로스루 시작
|
||||
if (_armed)
|
||||
{
|
||||
_armed = false;
|
||||
_recovering = true;
|
||||
_recoverElapsed = 0f;
|
||||
_recoverFrom = transform.localRotation; // 현재 위치에서 복귀(스냅 방지)
|
||||
}
|
||||
|
||||
// 3) 팔로스루: 현재 → 대기 자세로 부드럽게
|
||||
if (_recovering)
|
||||
{
|
||||
_recoverElapsed += Time.deltaTime;
|
||||
float r = _recoverTime > 0f ? Mathf.Clamp01(_recoverElapsed / _recoverTime) : 1f;
|
||||
transform.localRotation = Quaternion.Slerp(_recoverFrom, _restRot, Mathf.SmoothStep(0f, 1f, r));
|
||||
if (r >= 1f) _recovering = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 4) 평상시 대기 자세
|
||||
transform.localRotation = _restRot;
|
||||
}
|
||||
|
||||
// 즉시 대기 자세로 되돌림 (오토플레이 종료 시 호출)
|
||||
public void ResetPose()
|
||||
{
|
||||
_armed = false;
|
||||
_recovering = false;
|
||||
transform.localRotation = _restRot;
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Rhythm/RhythmStick.cs.meta
Normal file
2
Assets/02_Scripts/Rhythm/RhythmStick.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6ae74de6faba37f4ca52b1c6177a98bf
|
||||
50
Assets/02_Scripts/UI/RhythmResultHud.cs
Normal file
50
Assets/02_Scripts/UI/RhythmResultHud.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
|
||||
// 곡 종료 후 결과창. 총점/판정수/정확도/등급/최대콤보를 표시한다.
|
||||
public class RhythmResultHud : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private RhythmManager _manager;
|
||||
[SerializeField] private GameObject _root; // 결과창 루트
|
||||
[SerializeField] private TMP_Text _scoreText;
|
||||
[SerializeField] private TMP_Text _maxComboText;
|
||||
[SerializeField] private TMP_Text _countText; // Perfect/Good/Bad/Miss 모아서 표시
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (_root != null) _root.SetActive(false);
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_manager == null) return;
|
||||
_manager.OnSongStarted += Hide;
|
||||
_manager.OnSongFinished += Show;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_manager == null) return;
|
||||
_manager.OnSongStarted -= Hide;
|
||||
_manager.OnSongFinished -= Show;
|
||||
}
|
||||
|
||||
private void Hide()
|
||||
{
|
||||
if (_root != null) _root.SetActive(false); // 곡 시작/재시작 시 이전 결과창 숨김
|
||||
}
|
||||
|
||||
private void Show(RhythmScore score)
|
||||
{
|
||||
if (_scoreText != null) _scoreText.text = $"{score.Score:N0}";
|
||||
if (_maxComboText != null) _maxComboText.text = $"{score.MaxCombo}";
|
||||
if (_countText != null)
|
||||
_countText.text =
|
||||
$"Perfect {score.PerfectCount}\n" +
|
||||
$"Good {score.GoodCount}\n" +
|
||||
$"Bad {score.BadCount}\n" +
|
||||
$"Miss {score.MissCount}";
|
||||
|
||||
if (_root != null) _root.SetActive(true);
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/UI/RhythmResultHud.cs.meta
Normal file
2
Assets/02_Scripts/UI/RhythmResultHud.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b187e420986956a4ba9407759ed4050f
|
||||
56
Assets/02_Scripts/UI/RhythmScoreHud.cs
Normal file
56
Assets/02_Scripts/UI/RhythmScoreHud.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
|
||||
// 플레이 중 실시간 점수/콤보 HUD. RhythmManager 이벤트만 구독하고 표시만 한다.
|
||||
public class RhythmScoreHud : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private RhythmManager _manager;
|
||||
[SerializeField] private GameObject _root; // HUD 루트 (곡 시작/종료에 켜고 끔)
|
||||
[SerializeField] private TMP_Text _scoreText;
|
||||
[SerializeField] private TMP_Text _comboText;
|
||||
[SerializeField] private TMP_Text _judgeText; // Perfect/Good 등 최근 판정 표시(선택)
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (_root != null) _root.SetActive(false); // 곡 시작 전엔 숨김
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_manager == null) return;
|
||||
_manager.OnSongStarted += HandleSongStarted;
|
||||
_manager.OnScoreChanged += HandleScoreChanged;
|
||||
_manager.OnJudged += HandleJudged;
|
||||
_manager.OnSongFinished += HandleSongFinished;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_manager == null) return;
|
||||
_manager.OnSongStarted -= HandleSongStarted;
|
||||
_manager.OnScoreChanged -= HandleScoreChanged;
|
||||
_manager.OnJudged -= HandleJudged;
|
||||
_manager.OnSongFinished -= HandleSongFinished;
|
||||
}
|
||||
|
||||
private void HandleSongStarted()
|
||||
{
|
||||
if (_root != null) _root.SetActive(true); // 곡 시작 시 실시간 HUD 표시
|
||||
}
|
||||
|
||||
private void HandleScoreChanged(RhythmScore score)
|
||||
{
|
||||
if (_scoreText != null) _scoreText.text = $"SCORE {score.Score:N0}";
|
||||
if (_comboText != null) _comboText.text = score.Combo > 0 ? $"{score.Combo}" : "";
|
||||
}
|
||||
|
||||
private void HandleJudged(Result result)
|
||||
{
|
||||
if (_judgeText != null) _judgeText.text = result.ToString();
|
||||
}
|
||||
|
||||
private void HandleSongFinished(RhythmScore score)
|
||||
{
|
||||
if (_root != null) _root.SetActive(false); // 곡 끝나면 실시간 HUD는 숨김(결과창이 대신 뜸)
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/UI/RhythmScoreHud.cs.meta
Normal file
2
Assets/02_Scripts/UI/RhythmScoreHud.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2d2c41822c633294a986c6803818303d
|
||||
Reference in New Issue
Block a user