2026-06-15 리듬게임 프로토타입

This commit is contained in:
skrwns304@gmail.com
2026-06-15 15:29:54 +09:00
parent 19c26533f8
commit 6fe34d8eec
23 changed files with 265 additions and 26 deletions

View File

@@ -114,6 +114,13 @@ public static List<Note> Parse(byte[] data, float offset, float bpmOverride, Lis
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

View File

@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using Hovl;
using UnityEngine;
using UnityEngine.InputSystem;
@@ -10,20 +9,26 @@ public class RhythmManager : MonoBehaviour
[SerializeField] private AudioSource _audioSource;
[SerializeField] private RhythmChart _currentChart;
[SerializeField] private RhythmNoteSpawner _spawner;
[SerializeField] private float _leadTime = 2f; // 노트가 생성돼 판정선까지 흐르는 시간(초)
[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; // 내려칠 때마다 재생 (판정과 무관, 헛침 포함)
[SerializeField] private GameObject StartButtonObj;
public float SongTime => _audioSource.time; // 모든 타이밍의 기준
// 모든 타이밍의 기준. 오디오 클럭(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(); // 화면에 떠 있는(아직 처리 안 된) 노트들
@@ -43,24 +48,23 @@ private void Awake()
private void Start()
{
ChangeSong();
InputManager.Instance.OnKey_Left_Event += OnPlayerInput;
InputManager.Instance.OnKey_Right_Event += OnPlayerInput;
}
private void Update()
{
//재생 중이던 곡이 끝나면 주변음 복구
if (_isPlaying && !_audioSource.isPlaying)
if (!_isPlaying) return;
//곡이 끝나면(리드인 지나 곡 길이 도달) 정지·주변음 복구
if (SongTime >= _clipLength)
{
StopSong();
return;
}
if (!_isPlaying) return;
SpawnDueNotes();
// 테스트용: 스페이스로 판정 (실제 VR에서는 컨트롤러/콜라이더 입력으로 교체)
if (Keyboard.current != null && Keyboard.current.spaceKey.wasPressedThisFrame)
OnPlayerInput();
}
// SongTime 기준으로 소환할 때가 된 노트들을 순서대로 생성
@@ -95,7 +99,13 @@ public void StartSong()
OnScoreChanged?.Invoke(Score); // HUD 초기화(0점)
StartButtonObj.SetActive(false);
_audioSource.Play();
_clipLength = _audioSource.clip != null ? _audioSource.clip.length : 0f;
// 리드인: 지금부터 _leadTime 뒤에 오디오 시작.
// 그 사이 SongTime이 -_leadTime→0으로 흘러 첫 노트(0초 부근)도 충분히 날아온다
_dspSongStart = AudioSettings.dspTime + _leadTime;
_audioSource.PlayScheduled(_dspSongStart);
_isPlaying = true;
//BGM·환경음을 낮춰 리듬게임 소리만 들리게
@@ -125,7 +135,13 @@ private void OnNoteMissed(RhythmNoteInstance note)
public void OnPlayerInput()
{
if (!_isPlaying || _activeNotes.Count == 0) return;
if (!_isPlaying) return;
// 판정과 무관하게 내려칠 때마다 효과음 (헛침 포함)
if (_hitSfx != null && SoundManager.Instance != null)
SoundManager.Instance.PlaySFX(_hitSfx);
if (_activeNotes.Count == 0) return;
// 판정선에 가장 가까운(시간차 최소) 노트 탐색
RhythmNoteInstance target = null;

View File

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

View File

@@ -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, songTime, onMiss);
return instance;
}
}

Binary file not shown.

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 24bc7742b39f0084ba058c0192831c60
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 100100000
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6ef8d383193e61348839e50435858283
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 100100000
userData:
assetBundleName:
assetBundleVariant:

View File

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

Binary file not shown.

Binary file not shown.

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 8a4a85f29dd93ba4cbb7bbfc3aedcfee
guid: 4b989a765a23e6748b978c76d50ab315
TextScriptImporter:
externalObjects: {}
userData:

Binary file not shown.

View File

@@ -0,0 +1,23 @@
fileFormatVersion: 2
guid: 0e08aeecbf5318f48b313c36c73fec53
AudioImporter:
externalObjects: {}
serializedVersion: 8
defaultSettings:
serializedVersion: 2
loadType: 0
sampleRateSetting: 0
sampleRateOverride: 44100
compressionFormat: 0
quality: 1
conversionMode: 0
preloadAudioData: 1
platformSettingOverrides: {}
forceToMono: 0
normalize: 1
loadInBackground: 0
ambisonic: 0
3D: 1
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1 +0,0 @@
지울것

Binary file not shown.

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: d6b71773bcf77be4bbf06915c1cabb06
guid: bfc25b7b5f6b3bd49a801d28d4367354
AudioImporter:
externalObjects: {}
serializedVersion: 8

View File

@@ -100,6 +100,24 @@ public @GameInput()
""processors"": """",
""interactions"": """",
""initialStateCheck"": false
},
{
""name"": ""Key_Left"",
""type"": ""Button"",
""id"": ""5afd9129-22e8-4e22-9843-f936922fc2a9"",
""expectedControlType"": """",
""processors"": """",
""interactions"": """",
""initialStateCheck"": false
},
{
""name"": ""Key_Right"",
""type"": ""Button"",
""id"": ""5d342104-f81c-46cf-af37-f9388136115b"",
""expectedControlType"": """",
""processors"": """",
""interactions"": """",
""initialStateCheck"": false
}
],
""bindings"": [
@@ -113,6 +131,28 @@ public @GameInput()
""action"": ""Jump"",
""isComposite"": false,
""isPartOfComposite"": false
},
{
""name"": """",
""id"": ""8de0c981-8048-4b3e-baf8-d77cf3dbcc39"",
""path"": ""<Keyboard>/leftArrow"",
""interactions"": """",
""processors"": """",
""groups"": """",
""action"": ""Key_Left"",
""isComposite"": false,
""isPartOfComposite"": false
},
{
""name"": """",
""id"": ""e2dedd13-89db-4921-8a87-303eaded5f2e"",
""path"": ""<Keyboard>/rightArrow"",
""interactions"": """",
""processors"": """",
""groups"": """",
""action"": ""Key_Right"",
""isComposite"": false,
""isPartOfComposite"": false
}
]
}
@@ -122,6 +162,8 @@ public @GameInput()
// Player
m_Player = asset.FindActionMap("Player", throwIfNotFound: true);
m_Player_Jump = m_Player.FindAction("Jump", throwIfNotFound: true);
m_Player_Key_Left = m_Player.FindAction("Key_Left", throwIfNotFound: true);
m_Player_Key_Right = m_Player.FindAction("Key_Right", throwIfNotFound: true);
}
~@GameInput()
@@ -203,6 +245,8 @@ public int FindBinding(InputBinding bindingMask, out InputAction action)
private readonly InputActionMap m_Player;
private List<IPlayerActions> m_PlayerActionsCallbackInterfaces = new List<IPlayerActions>();
private readonly InputAction m_Player_Jump;
private readonly InputAction m_Player_Key_Left;
private readonly InputAction m_Player_Key_Right;
/// <summary>
/// Provides access to input actions defined in input action map "Player".
/// </summary>
@@ -219,6 +263,14 @@ public struct PlayerActions
/// </summary>
public InputAction @Jump => m_Wrapper.m_Player_Jump;
/// <summary>
/// Provides access to the underlying input action "Player/Key_Left".
/// </summary>
public InputAction @Key_Left => m_Wrapper.m_Player_Key_Left;
/// <summary>
/// Provides access to the underlying input action "Player/Key_Right".
/// </summary>
public InputAction @Key_Right => m_Wrapper.m_Player_Key_Right;
/// <summary>
/// Provides access to the underlying input action map instance.
/// </summary>
public InputActionMap Get() { return m_Wrapper.m_Player; }
@@ -247,6 +299,12 @@ public void AddCallbacks(IPlayerActions instance)
@Jump.started += instance.OnJump;
@Jump.performed += instance.OnJump;
@Jump.canceled += instance.OnJump;
@Key_Left.started += instance.OnKey_Left;
@Key_Left.performed += instance.OnKey_Left;
@Key_Left.canceled += instance.OnKey_Left;
@Key_Right.started += instance.OnKey_Right;
@Key_Right.performed += instance.OnKey_Right;
@Key_Right.canceled += instance.OnKey_Right;
}
/// <summary>
@@ -261,6 +319,12 @@ private void UnregisterCallbacks(IPlayerActions instance)
@Jump.started -= instance.OnJump;
@Jump.performed -= instance.OnJump;
@Jump.canceled -= instance.OnJump;
@Key_Left.started -= instance.OnKey_Left;
@Key_Left.performed -= instance.OnKey_Left;
@Key_Left.canceled -= instance.OnKey_Left;
@Key_Right.started -= instance.OnKey_Right;
@Key_Right.performed -= instance.OnKey_Right;
@Key_Right.canceled -= instance.OnKey_Right;
}
/// <summary>
@@ -308,5 +372,19 @@ public interface IPlayerActions
/// <seealso cref="UnityEngine.InputSystem.InputAction.performed" />
/// <seealso cref="UnityEngine.InputSystem.InputAction.canceled" />
void OnJump(InputAction.CallbackContext context);
/// <summary>
/// Method invoked when associated input action "Key_Left" is either <see cref="UnityEngine.InputSystem.InputAction.started" />, <see cref="UnityEngine.InputSystem.InputAction.performed" /> or <see cref="UnityEngine.InputSystem.InputAction.canceled" />.
/// </summary>
/// <seealso cref="UnityEngine.InputSystem.InputAction.started" />
/// <seealso cref="UnityEngine.InputSystem.InputAction.performed" />
/// <seealso cref="UnityEngine.InputSystem.InputAction.canceled" />
void OnKey_Left(InputAction.CallbackContext context);
/// <summary>
/// Method invoked when associated input action "Key_Right" is either <see cref="UnityEngine.InputSystem.InputAction.started" />, <see cref="UnityEngine.InputSystem.InputAction.performed" /> or <see cref="UnityEngine.InputSystem.InputAction.canceled" />.
/// </summary>
/// <seealso cref="UnityEngine.InputSystem.InputAction.started" />
/// <seealso cref="UnityEngine.InputSystem.InputAction.performed" />
/// <seealso cref="UnityEngine.InputSystem.InputAction.canceled" />
void OnKey_Right(InputAction.CallbackContext context);
}
}

View File

@@ -14,6 +14,24 @@
"processors": "",
"interactions": "",
"initialStateCheck": false
},
{
"name": "Key_Left",
"type": "Button",
"id": "5afd9129-22e8-4e22-9843-f936922fc2a9",
"expectedControlType": "",
"processors": "",
"interactions": "",
"initialStateCheck": false
},
{
"name": "Key_Right",
"type": "Button",
"id": "5d342104-f81c-46cf-af37-f9388136115b",
"expectedControlType": "",
"processors": "",
"interactions": "",
"initialStateCheck": false
}
],
"bindings": [
@@ -27,6 +45,28 @@
"action": "Jump",
"isComposite": false,
"isPartOfComposite": false
},
{
"name": "",
"id": "8de0c981-8048-4b3e-baf8-d77cf3dbcc39",
"path": "<Keyboard>/leftArrow",
"interactions": "",
"processors": "",
"groups": "",
"action": "Key_Left",
"isComposite": false,
"isPartOfComposite": false
},
{
"name": "",
"id": "e2dedd13-89db-4921-8a87-303eaded5f2e",
"path": "<Keyboard>/rightArrow",
"interactions": "",
"processors": "",
"groups": "",
"action": "Key_Right",
"isComposite": false,
"isPartOfComposite": false
}
]
}

Binary file not shown.

Binary file not shown.