c335995a9a
- SongSelectManager/SongDetailPanel: 곡 선택 및 상세 패널 개선 - SongCreatorManager: 곡 생성 기능 추가 - FinalScoreLabel/ScoreManager: 결과 화면 점수 UI 업데이트 - MarqueeText: 마퀴 텍스트 컴포넌트 개선 - NoteData/SongController: 노트 데이터 및 컨트롤러 보완 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
385 lines
14 KiB
C#
385 lines
14 KiB
C#
using System;
|
|
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using TMPro;
|
|
using UnityEngine;
|
|
using UnityEngine.SceneManagement;
|
|
using UnityEngine.UI;
|
|
|
|
public class SongDetailPanel : MonoBehaviour
|
|
{
|
|
[Header("곡 정보")]
|
|
[SerializeField] private TMP_Text titleText;
|
|
[SerializeField] private TMP_Text artistText;
|
|
[SerializeField] private TMP_Text infoText;
|
|
|
|
[Header("난이도 버튼")]
|
|
[SerializeField] private Button btnNormal;
|
|
[SerializeField] private Button btnHard;
|
|
[SerializeField] private Button btnExpert;
|
|
[SerializeField] private Button btnExpertPlus;
|
|
|
|
[Header("액션 버튼")]
|
|
[SerializeField] private Button downloadButton;
|
|
[SerializeField] private Button deleteButton;
|
|
[SerializeField] private Button playButton;
|
|
[SerializeField] private Button closeButton;
|
|
|
|
[Header("진행률")]
|
|
[SerializeField] private GameObject progressGroup;
|
|
[SerializeField] private Slider progressSlider;
|
|
[SerializeField] private TMP_Text progressText;
|
|
|
|
[Header("씬 이름")]
|
|
[SerializeField] private string gameSceneName = "Game";
|
|
|
|
private static readonly Color NeonBg = new Color(0.05f, 0.82f, 0.95f, 0.42f);
|
|
private static readonly Color DarkButtonBg = new Color(0.09f, 0.22f, 0.27f, 0.66f);
|
|
private static readonly Color DisabledBg = new Color(0.06f, 0.12f, 0.15f, 0.48f);
|
|
private static readonly Color DangerBg = new Color(0.52f, 0.16f, 0.22f, 0.72f);
|
|
private static readonly Color ButtonText = new Color(0.92f, 1.0f, 1.0f, 1.0f);
|
|
private static readonly Color MutedText = new Color(0.66f, 0.80f, 0.84f, 0.76f);
|
|
private static readonly Color NeonOutline = new Color(0.25f, 0.96f, 1.0f, 0.42f);
|
|
|
|
private SongInfo currentSong;
|
|
private string selectedDifficulty;
|
|
private DownloadManager downloadManager;
|
|
private SongSelectManager selectManager;
|
|
private MarqueeText titleMarquee;
|
|
private MarqueeText artistMarquee;
|
|
|
|
private readonly (string key, Func<SongDetailPanel, Button> btn)[] diffSlots =
|
|
{
|
|
("normal", p => p.btnNormal),
|
|
("hard", p => p.btnHard),
|
|
("expert", p => p.btnExpert),
|
|
("expertplus", p => p.btnExpertPlus),
|
|
};
|
|
|
|
private void Awake()
|
|
{
|
|
HideDifficultyLabel();
|
|
titleMarquee = ConfigureMarqueeText(titleText, 5.0f, 7.2f);
|
|
artistMarquee = ConfigureMarqueeText(artistText, 3.4f, 4.4f);
|
|
ConfigureOneLineText(infoText, 3.2f, 4.2f, TextAlignmentOptions.MidlineLeft);
|
|
ConfigureButtonText(btnNormal, 3.2f, 4.0f);
|
|
ConfigureButtonText(btnHard, 3.2f, 4.0f);
|
|
ConfigureButtonText(btnExpert, 3.2f, 4.0f);
|
|
ConfigureButtonText(btnExpertPlus, 3.0f, 3.8f);
|
|
ConfigureButtonText(downloadButton, 3.5f, 4.4f);
|
|
ConfigureButtonText(deleteButton, 3.5f, 4.4f);
|
|
ConfigureButtonText(playButton, 3.5f, 4.4f);
|
|
ConfigureButtonText(closeButton, 5.2f, 6.4f);
|
|
}
|
|
|
|
// ── Public API ───────────────────────────────────────────
|
|
|
|
public void Show(SongInfo song, DownloadManager dm, SongSelectManager sm)
|
|
{
|
|
currentSong = song;
|
|
downloadManager = dm;
|
|
selectManager = sm;
|
|
selectedDifficulty = null;
|
|
|
|
titleText.text = song.title;
|
|
artistText.text = song.artist;
|
|
infoText.text = song.duration > 0
|
|
? $"BPM {Mathf.RoundToInt(song.bpm)} {FormatDuration(song.duration)}"
|
|
: $"BPM {Mathf.RoundToInt(song.bpm)}";
|
|
|
|
titleMarquee?.Refresh();
|
|
artistMarquee?.Refresh();
|
|
RefreshUI();
|
|
}
|
|
|
|
// ── UI 갱신 ──────────────────────────────────────────────
|
|
|
|
private void RefreshUI()
|
|
{
|
|
bool downloaded = SongLibrary.Instance.IsSongDownloaded(currentSong.id);
|
|
|
|
foreach (var (key, getBtn) in diffSlots)
|
|
{
|
|
Button btn = getBtn(this);
|
|
bool exists = currentSong.difficulties.Get(key) != null;
|
|
|
|
btn.interactable = downloaded && exists;
|
|
btn.onClick.RemoveAllListeners();
|
|
|
|
if (downloaded && exists)
|
|
{
|
|
string captured = key;
|
|
btn.onClick.AddListener(() => SelectDifficulty(captured));
|
|
}
|
|
}
|
|
|
|
UpdateDiffColors();
|
|
|
|
downloadButton.gameObject.SetActive(!downloaded);
|
|
deleteButton.gameObject.SetActive(downloaded);
|
|
downloadButton.interactable = !downloaded;
|
|
deleteButton.interactable = downloaded;
|
|
playButton.interactable = downloaded && selectedDifficulty != null;
|
|
progressGroup.SetActive(false);
|
|
UpdateActionButtonStyles(downloaded);
|
|
|
|
downloadButton.onClick.RemoveAllListeners();
|
|
downloadButton.onClick.AddListener(OnDownloadClicked);
|
|
|
|
deleteButton.onClick.RemoveAllListeners();
|
|
deleteButton.onClick.AddListener(OnDeleteClicked);
|
|
|
|
playButton.onClick.RemoveAllListeners();
|
|
playButton.onClick.AddListener(OnPlayClicked);
|
|
|
|
if (closeButton != null)
|
|
{
|
|
closeButton.onClick.RemoveAllListeners();
|
|
closeButton.onClick.AddListener(() => gameObject.SetActive(false));
|
|
}
|
|
}
|
|
|
|
private void SelectDifficulty(string difficulty)
|
|
{
|
|
selectedDifficulty = difficulty;
|
|
playButton.interactable = true;
|
|
UpdateDiffColors();
|
|
UpdateActionButtonStyles(true);
|
|
}
|
|
|
|
private void UpdateDiffColors()
|
|
{
|
|
foreach (var (key, getBtn) in diffSlots)
|
|
{
|
|
Button btn = getBtn(this);
|
|
bool selected = key == selectedDifficulty;
|
|
|
|
ApplyButtonStyle(btn, selected ? NeonBg : DarkButtonBg, selected, btn.interactable, false);
|
|
}
|
|
}
|
|
|
|
// ── 다운로드 ──────────────────────────────────────────────
|
|
|
|
private void OnDownloadClicked()
|
|
{
|
|
StartCoroutine(DownloadAllCoroutine());
|
|
}
|
|
|
|
private IEnumerator DownloadAllCoroutine()
|
|
{
|
|
var diffs = new List<string>();
|
|
foreach (var (key, _) in diffSlots)
|
|
if (currentSong.difficulties.Get(key) != null)
|
|
diffs.Add(key);
|
|
|
|
if (diffs.Count == 0) yield break;
|
|
|
|
SetInteractable(false);
|
|
progressGroup.SetActive(true);
|
|
downloadButton.gameObject.SetActive(false);
|
|
deleteButton.gameObject.SetActive(false);
|
|
playButton.gameObject.SetActive(false);
|
|
|
|
int totalSteps = diffs.Count;
|
|
int doneSteps = 0;
|
|
bool failed = false;
|
|
|
|
foreach (string diff in diffs)
|
|
{
|
|
bool stepDone = false;
|
|
|
|
downloadManager.DownloadSong(
|
|
currentSong, diff,
|
|
onProgress: p =>
|
|
{
|
|
float overall = (doneSteps + p) / totalSteps;
|
|
progressSlider.value = overall;
|
|
progressText.text = $"{diffs[Mathf.Min(doneSteps, diffs.Count - 1)].ToUpper()} {(int)(overall * 100)}%";
|
|
},
|
|
onComplete: () =>
|
|
{
|
|
SongLibrary.Instance.MarkDownloaded(currentSong.id, diff);
|
|
doneSteps++;
|
|
stepDone = true;
|
|
},
|
|
onError: err =>
|
|
{
|
|
Debug.LogError($"[SongDetailPanel] {err}");
|
|
failed = true;
|
|
stepDone = true;
|
|
});
|
|
|
|
yield return new WaitUntil(() => stepDone);
|
|
if (failed) break;
|
|
}
|
|
|
|
SetInteractable(true);
|
|
progressGroup.SetActive(false);
|
|
playButton.gameObject.SetActive(true);
|
|
selectManager.RefreshCards();
|
|
RefreshUI();
|
|
|
|
if (!failed)
|
|
Debug.Log($"[SongDetailPanel] '{currentSong.title}' 전체 다운로드 완료");
|
|
}
|
|
|
|
// ── 삭제 ─────────────────────────────────────────────────
|
|
|
|
private void OnDeleteClicked()
|
|
{
|
|
downloadManager.DeleteSong(currentSong.id);
|
|
SongLibrary.Instance.MarkSongRemoved(currentSong.id);
|
|
selectedDifficulty = null;
|
|
selectManager.RefreshCards();
|
|
RefreshUI();
|
|
}
|
|
|
|
// ── 플레이 ───────────────────────────────────────────────
|
|
|
|
private void OnPlayClicked()
|
|
{
|
|
GameSession.SelectedSong = currentSong;
|
|
GameSession.SelectedDifficulty = selectedDifficulty;
|
|
SceneManager.LoadScene(gameSceneName);
|
|
}
|
|
|
|
// ── 유틸 ─────────────────────────────────────────────────
|
|
|
|
private void SetInteractable(bool value)
|
|
{
|
|
downloadButton.interactable = value;
|
|
deleteButton.interactable = value;
|
|
playButton.interactable = value && selectedDifficulty != null;
|
|
foreach (var (_, getBtn) in diffSlots)
|
|
getBtn(this).interactable = value;
|
|
}
|
|
|
|
private static string FormatDuration(int seconds)
|
|
=> $"{seconds / 60}:{seconds % 60:D2}";
|
|
|
|
private void HideDifficultyLabel()
|
|
{
|
|
Transform label = transform.Find("LblDifficulty");
|
|
if (label != null)
|
|
label.gameObject.SetActive(false);
|
|
}
|
|
|
|
private void UpdateActionButtonStyles(bool downloaded)
|
|
{
|
|
ApplyButtonStyle(downloadButton, NeonBg, true, !downloaded, false);
|
|
ApplyButtonStyle(deleteButton, DangerBg, true, downloaded, true);
|
|
ApplyButtonStyle(playButton, NeonBg, true, playButton.interactable, false);
|
|
ApplyButtonStyle(closeButton, DarkButtonBg, false, true, false);
|
|
}
|
|
|
|
private static void ApplyButtonStyle(Button btn, Color activeBg, bool outlined, bool enabled, bool danger)
|
|
{
|
|
if (btn == null)
|
|
return;
|
|
|
|
Color bg = enabled ? activeBg : DisabledBg;
|
|
if (btn.targetGraphic is Image img)
|
|
img.color = bg;
|
|
|
|
var colors = btn.colors;
|
|
colors.normalColor = bg;
|
|
colors.highlightedColor = enabled
|
|
? (danger ? new Color(0.72f, 0.23f, 0.30f, 0.86f) : new Color(0.10f, 0.95f, 1.0f, 0.58f))
|
|
: DisabledBg;
|
|
colors.pressedColor = enabled
|
|
? (danger ? new Color(0.42f, 0.10f, 0.15f, 0.92f) : new Color(0.02f, 0.58f, 0.72f, 0.80f))
|
|
: DisabledBg;
|
|
colors.selectedColor = colors.highlightedColor;
|
|
colors.disabledColor = DisabledBg;
|
|
colors.fadeDuration = 0.08f;
|
|
btn.colors = colors;
|
|
|
|
TMP_Text label = btn.GetComponentInChildren<TMP_Text>();
|
|
if (label != null)
|
|
label.color = enabled ? ButtonText : MutedText;
|
|
|
|
Outline outline = btn.GetComponent<Outline>() ?? btn.gameObject.AddComponent<Outline>();
|
|
outline.enabled = outlined && enabled;
|
|
outline.effectColor = danger ? new Color(1.0f, 0.35f, 0.42f, 0.34f) : NeonOutline;
|
|
outline.effectDistance = new Vector2(0.0f, -0.28f);
|
|
}
|
|
|
|
private static MarqueeText ConfigureMarqueeText(TMP_Text text, float minSize, float maxSize)
|
|
{
|
|
if (text == null)
|
|
return null;
|
|
|
|
RectTransform textRect = text.rectTransform;
|
|
Transform originalParent = textRect.parent;
|
|
int siblingIndex = textRect.GetSiblingIndex();
|
|
string maskName = $"{text.name}Mask";
|
|
Transform existingMask = originalParent != null ? originalParent.Find(maskName) : null;
|
|
RectTransform maskRect;
|
|
|
|
if (existingMask != null)
|
|
{
|
|
maskRect = existingMask as RectTransform;
|
|
if (textRect.parent != existingMask)
|
|
textRect.SetParent(existingMask, false);
|
|
}
|
|
else
|
|
{
|
|
var mask = new GameObject(maskName);
|
|
mask.transform.SetParent(originalParent, false);
|
|
mask.transform.SetSiblingIndex(siblingIndex);
|
|
maskRect = mask.AddComponent<RectTransform>();
|
|
maskRect.anchorMin = textRect.anchorMin;
|
|
maskRect.anchorMax = textRect.anchorMax;
|
|
maskRect.pivot = textRect.pivot;
|
|
maskRect.anchoredPosition = textRect.anchoredPosition;
|
|
maskRect.sizeDelta = textRect.sizeDelta;
|
|
maskRect.localRotation = textRect.localRotation;
|
|
maskRect.localScale = textRect.localScale;
|
|
mask.AddComponent<RectMask2D>();
|
|
textRect.SetParent(mask.transform, false);
|
|
}
|
|
|
|
textRect.anchorMin = new Vector2(0f, 0f);
|
|
textRect.anchorMax = new Vector2(0f, 1f);
|
|
textRect.pivot = new Vector2(0f, 0.5f);
|
|
textRect.anchoredPosition = Vector2.zero;
|
|
textRect.localRotation = Quaternion.identity;
|
|
textRect.localScale = Vector3.one;
|
|
textRect.sizeDelta = new Vector2(260.0f, 0f);
|
|
|
|
ConfigureOneLineText(text, minSize, maxSize, TextAlignmentOptions.MidlineLeft);
|
|
text.overflowMode = TextOverflowModes.Overflow;
|
|
text.raycastTarget = false;
|
|
|
|
MarqueeText marquee = text.GetComponent<MarqueeText>() ?? text.gameObject.AddComponent<MarqueeText>();
|
|
marquee.speed = 9f;
|
|
marquee.pauseStart = 1.25f;
|
|
marquee.pauseEnd = 0.8f;
|
|
return marquee;
|
|
}
|
|
|
|
private static void ConfigureButtonText(Button btn, float minSize, float maxSize)
|
|
{
|
|
if (btn == null)
|
|
return;
|
|
|
|
TMP_Text label = btn.GetComponentInChildren<TMP_Text>();
|
|
ConfigureOneLineText(label, minSize, maxSize, TextAlignmentOptions.Center);
|
|
if (label != null)
|
|
label.raycastTarget = false;
|
|
}
|
|
|
|
private static void ConfigureOneLineText(TMP_Text text, float minSize, float maxSize, TextAlignmentOptions alignment)
|
|
{
|
|
if (text == null)
|
|
return;
|
|
|
|
text.enableAutoSizing = true;
|
|
text.fontSizeMin = minSize;
|
|
text.fontSizeMax = maxSize;
|
|
text.alignment = alignment;
|
|
text.overflowMode = TextOverflowModes.Ellipsis;
|
|
text.textWrappingMode = TextWrappingModes.NoWrap;
|
|
}
|
|
}
|