c335995a9a
- SongSelectManager/SongDetailPanel: 곡 선택 및 상세 패널 개선 - SongCreatorManager: 곡 생성 기능 추가 - FinalScoreLabel/ScoreManager: 결과 화면 점수 UI 업데이트 - MarqueeText: 마퀴 텍스트 컴포넌트 개선 - NoteData/SongController: 노트 데이터 및 컨트롤러 보완 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
329 lines
12 KiB
C#
329 lines
12 KiB
C#
using System.Collections.Generic;
|
|
using System.IO;
|
|
using TMPro;
|
|
using UnityEngine;
|
|
using UnityEngine.UI;
|
|
|
|
public class SongSelectManager : MonoBehaviour
|
|
{
|
|
[SerializeField] private Button tabAllBtn;
|
|
[SerializeField] private Button tabOwnedBtn;
|
|
[SerializeField] private RectTransform cardContainer;
|
|
[SerializeField] private SongDetailPanel detailPanel;
|
|
[SerializeField] private DownloadManager downloadManager;
|
|
[SerializeField] private GameObject loadingOverlay;
|
|
[SerializeField] private GameObject errorOverlay;
|
|
[SerializeField] private TMP_Text errorText;
|
|
|
|
|
|
private static readonly Color TabActiveBg = new Color(0.05f, 0.82f, 0.95f, 0.42f);
|
|
private static readonly Color TabInactiveBg = new Color(0.09f, 0.22f, 0.27f, 0.66f);
|
|
private static readonly Color TabActiveText = new Color(0.92f, 1.0f, 1.0f, 1.0f);
|
|
private static readonly Color TabInactiveText = new Color(0.72f, 0.86f, 0.90f, 0.82f);
|
|
private static readonly Color TabActiveOutline = new Color(0.25f, 0.96f, 1.0f, 0.55f);
|
|
|
|
private static string CachePath =>
|
|
Path.Combine(Application.persistentDataPath, "songs_cache.json");
|
|
|
|
private List<SongInfo> allSongs = new List<SongInfo>();
|
|
private bool showingOwned = false;
|
|
private TMP_FontAsset _cardFont;
|
|
|
|
private void Start()
|
|
{
|
|
// NanumGothic SDF를 직접 로드 — Resources 경로에 있어야 함
|
|
_cardFont = Resources.Load<TMP_FontAsset>("Fonts & Materials/NanumGothic SDF");
|
|
if (_cardFont == null)
|
|
_cardFont = Resources.Load<TMP_FontAsset>("Fonts & Materials/LiberationSans SDF");
|
|
|
|
tabAllBtn .onClick.AddListener(() => SwitchTab(false));
|
|
tabOwnedBtn.onClick.AddListener(() => SwitchTab(true));
|
|
detailPanel.gameObject.SetActive(false);
|
|
SetTabVisual(false);
|
|
FetchSongs();
|
|
}
|
|
|
|
private void SwitchTab(bool owned)
|
|
{
|
|
showingOwned = owned;
|
|
SetTabVisual(owned);
|
|
RefreshCards();
|
|
}
|
|
|
|
private void SetTabVisual(bool owned)
|
|
{
|
|
ApplyTabStyle(tabAllBtn, !owned);
|
|
ApplyTabStyle(tabOwnedBtn, owned);
|
|
}
|
|
|
|
private static void ApplyTabStyle(Button btn, bool active)
|
|
{
|
|
if (btn == null)
|
|
return;
|
|
|
|
Color bg = active ? TabActiveBg : TabInactiveBg;
|
|
if (btn.targetGraphic is Image img)
|
|
img.color = bg;
|
|
|
|
var colors = btn.colors;
|
|
colors.normalColor = bg;
|
|
colors.highlightedColor = active
|
|
? new Color(0.10f, 0.95f, 1.0f, 0.58f)
|
|
: new Color(0.14f, 0.34f, 0.40f, 0.72f);
|
|
colors.pressedColor = active
|
|
? new Color(0.02f, 0.58f, 0.72f, 0.72f)
|
|
: new Color(0.08f, 0.20f, 0.24f, 0.82f);
|
|
colors.selectedColor = colors.highlightedColor;
|
|
colors.disabledColor = new Color(0.05f, 0.10f, 0.12f, 0.45f);
|
|
btn.colors = colors;
|
|
|
|
TMP_Text label = btn.GetComponentInChildren<TMP_Text>();
|
|
if (label != null)
|
|
label.color = active ? TabActiveText : TabInactiveText;
|
|
|
|
Outline outline = btn.GetComponent<Outline>() ?? btn.gameObject.AddComponent<Outline>();
|
|
outline.enabled = active;
|
|
outline.effectColor = TabActiveOutline;
|
|
outline.effectDistance = new Vector2(0.0f, -0.35f);
|
|
}
|
|
|
|
private void FetchSongs()
|
|
{
|
|
loadingOverlay.SetActive(true);
|
|
errorOverlay .SetActive(false);
|
|
|
|
downloadManager.FetchSongsList(
|
|
onSuccess: list =>
|
|
{
|
|
allSongs = list.songs ?? new List<SongInfo>();
|
|
AddLocalForcedRankDummies(allSongs);
|
|
SaveCache(new SongsList { version = list.version, songs = allSongs });
|
|
SongLibrary.Instance.ValidateWithFileSystem(downloadManager, allSongs);
|
|
loadingOverlay.SetActive(false);
|
|
RefreshCards();
|
|
},
|
|
onError: _ =>
|
|
{
|
|
SongsList cached = LoadCache();
|
|
loadingOverlay.SetActive(false);
|
|
if (cached != null)
|
|
{
|
|
allSongs = cached.songs;
|
|
RefreshCards();
|
|
}
|
|
else
|
|
{
|
|
errorOverlay.SetActive(true);
|
|
errorText.text = "Failed to connect to server\nPlease check your internet connection";
|
|
}
|
|
});
|
|
}
|
|
|
|
public void RefreshCards()
|
|
{
|
|
// DestroyImmediate to avoid deferred-destroy interfering with layout
|
|
for (int i = cardContainer.childCount - 1; i >= 0; i--)
|
|
DestroyImmediate(cardContainer.GetChild(i).gameObject);
|
|
|
|
List<SongInfo> songs = showingOwned
|
|
? allSongs.FindAll(s => SongLibrary.Instance.IsSongDownloaded(s.id))
|
|
: allSongs;
|
|
|
|
foreach (SongInfo song in songs)
|
|
SpawnCard(song);
|
|
|
|
// Order matters: layout first → card gets size → then canvas update → anchored children recalculate
|
|
LayoutRebuilder.ForceRebuildLayoutImmediate(cardContainer);
|
|
Canvas.ForceUpdateCanvases();
|
|
}
|
|
|
|
private void SpawnCard(SongInfo song)
|
|
{
|
|
bool downloaded = SongLibrary.Instance.IsSongDownloaded(song.id);
|
|
|
|
var card = new GameObject(song.title);
|
|
card.transform.SetParent(cardContainer, false);
|
|
|
|
var le = card.AddComponent<LayoutElement>();
|
|
le.preferredHeight = 13f;
|
|
le.flexibleWidth = 1f;
|
|
|
|
var bg = card.AddComponent<Image>();
|
|
bg.color = new Color(1f, 1f, 1f, 0.06f);
|
|
|
|
var btn = card.AddComponent<Button>();
|
|
btn.targetGraphic = bg;
|
|
var bc = btn.colors;
|
|
bc.normalColor = new Color(1f, 1f, 1f, 0.06f);
|
|
bc.highlightedColor = new Color(0.4f, 0.75f, 1f, 0.25f);
|
|
bc.pressedColor = new Color(0.3f, 0.60f, 0.9f, 0.45f);
|
|
bc.fadeDuration = 0.1f;
|
|
btn.colors = bc;
|
|
|
|
float textLeftInset = downloaded ? 12f : 5f;
|
|
|
|
// Title — RectMask2D 컨테이너 안에서 마퀴 스크롤
|
|
var titleMask = new GameObject("TitleMask");
|
|
titleMask.transform.SetParent(card.transform, false);
|
|
var tmr = titleMask.AddComponent<RectTransform>();
|
|
tmr.anchorMin = new Vector2(0f, 0.5f);
|
|
tmr.anchorMax = new Vector2(1f, 1f);
|
|
tmr.offsetMin = new Vector2(textLeftInset, 0f);
|
|
tmr.offsetMax = new Vector2(-3f, 0f);
|
|
titleMask.AddComponent<RectMask2D>();
|
|
|
|
var titleGO = new GameObject("Title");
|
|
titleGO.transform.SetParent(titleMask.transform, false);
|
|
var tr = titleGO.AddComponent<RectTransform>();
|
|
tr.anchorMin = new Vector2(0f, 0f);
|
|
tr.anchorMax = new Vector2(0f, 1f);
|
|
tr.pivot = new Vector2(0f, 0.5f);
|
|
tr.anchoredPosition = Vector2.zero;
|
|
tr.sizeDelta = new Vector2(500f, 0f);
|
|
var tTmp = titleGO.AddComponent<TextMeshProUGUI>();
|
|
if (_cardFont != null) tTmp.font = _cardFont;
|
|
tTmp.text = song.title;
|
|
tTmp.fontSize = 5f;
|
|
tTmp.color = Color.white;
|
|
tTmp.alignment = TextAlignmentOptions.MidlineLeft;
|
|
tTmp.overflowMode = TextOverflowModes.Overflow;
|
|
tTmp.textWrappingMode = TextWrappingModes.NoWrap;
|
|
titleGO.AddComponent<MarqueeText>();
|
|
|
|
// Artist
|
|
var artistGO = new GameObject("Artist");
|
|
artistGO.transform.SetParent(card.transform, false);
|
|
var ar = artistGO.AddComponent<RectTransform>();
|
|
ar.anchorMin = new Vector2(0f, 0.04f);
|
|
ar.anchorMax = new Vector2(1f, 0.48f);
|
|
ar.offsetMin = new Vector2(textLeftInset, 0f);
|
|
ar.offsetMax = new Vector2(-3f, 0f);
|
|
var aTmp = artistGO.AddComponent<TextMeshProUGUI>();
|
|
if (_cardFont != null) aTmp.font = _cardFont;
|
|
aTmp.text = song.artist;
|
|
aTmp.fontSize = 4f;
|
|
aTmp.enableAutoSizing = true;
|
|
aTmp.fontSizeMin = 2.8f;
|
|
aTmp.fontSizeMax = 4f;
|
|
aTmp.color = new Color(1f, 1f, 1f, 0.6f);
|
|
aTmp.alignment = TextAlignmentOptions.MidlineLeft;
|
|
aTmp.overflowMode = TextOverflowModes.Ellipsis;
|
|
aTmp.textWrappingMode = TextWrappingModes.NoWrap;
|
|
|
|
// Downloaded check mark
|
|
if (downloaded)
|
|
{
|
|
var checkGO = new GameObject("OwnedCheck");
|
|
checkGO.transform.SetParent(card.transform, false);
|
|
var cr = checkGO.AddComponent<RectTransform>();
|
|
cr.anchorMin = new Vector2(0f, 0f);
|
|
cr.anchorMax = new Vector2(0f, 1f);
|
|
cr.pivot = new Vector2(0f, 0.5f);
|
|
cr.anchoredPosition = new Vector2(3.0f, 0f);
|
|
cr.sizeDelta = new Vector2(6f, 0f);
|
|
|
|
Color checkColor = new Color(0.36f, 1.0f, 0.58f, 0.95f);
|
|
CreateCheckStroke(checkGO.transform, "ShortStroke", new Vector2(1.8f, 7.1f), new Vector2(1.5f, 0.35f), 42.0f, checkColor);
|
|
CreateCheckStroke(checkGO.transform, "LongStroke", new Vector2(3.25f, 7.85f), new Vector2(3.7f, 0.35f), -45.0f, checkColor);
|
|
}
|
|
|
|
SongInfo captured = song;
|
|
btn.onClick.AddListener(() => OnCardClicked(captured));
|
|
}
|
|
|
|
private void OnCardClicked(SongInfo song)
|
|
{
|
|
detailPanel.gameObject.SetActive(true);
|
|
detailPanel.Show(song, downloadManager, this);
|
|
}
|
|
|
|
private static void CreateCheckStroke(Transform parent, string name, Vector2 anchoredPosition,
|
|
Vector2 size, float rotationZ, Color color)
|
|
{
|
|
var stroke = new GameObject(name);
|
|
stroke.transform.SetParent(parent, false);
|
|
|
|
var rect = stroke.AddComponent<RectTransform>();
|
|
rect.anchorMin = new Vector2(0f, 0f);
|
|
rect.anchorMax = new Vector2(0f, 0f);
|
|
rect.pivot = new Vector2(0.5f, 0.5f);
|
|
rect.anchoredPosition = anchoredPosition;
|
|
rect.sizeDelta = size;
|
|
rect.localRotation = Quaternion.Euler(0f, 0f, rotationZ);
|
|
|
|
var img = stroke.AddComponent<Image>();
|
|
img.color = color;
|
|
img.raycastTarget = false;
|
|
}
|
|
|
|
private static void SaveCache(SongsList list)
|
|
{
|
|
try { File.WriteAllText(CachePath, JsonUtility.ToJson(list, true)); }
|
|
catch { }
|
|
}
|
|
|
|
private static SongsList LoadCache()
|
|
{
|
|
if (!File.Exists(CachePath)) return null;
|
|
try { return JsonUtility.FromJson<SongsList>(File.ReadAllText(CachePath)); }
|
|
catch { return null; }
|
|
}
|
|
|
|
private static void AddLocalForcedRankDummies(List<SongInfo> songs)
|
|
{
|
|
string root = Path.Combine(Application.persistentDataPath, "beatsaber");
|
|
AddLocalForcedRankDummy(songs, root, "dummy_rank_m", "M", 10);
|
|
AddLocalForcedRankDummy(songs, root, "dummy_rank_splus", "S+", 100);
|
|
AddLocalForcedRankDummy(songs, root, "dummy_rank_s", "S", 100);
|
|
AddLocalForcedRankDummy(songs, root, "dummy_rank_a", "A", 100);
|
|
AddLocalForcedRankDummy(songs, root, "dummy_rank_b", "B", 100);
|
|
AddLocalForcedRankDummy(songs, root, "dummy_rank_c", "C", 100);
|
|
AddLocalForcedRankDummy(songs, root, "dummy_rank_d", "D", 100);
|
|
AddLocalForcedRankDummy(songs, root, "dummy_rank_f", "F", 100);
|
|
AddLocalForcedRankDummy(songs, root, "dummy_rank_splus_border", "S+ 98%", 100);
|
|
AddLocalForcedRankDummy(songs, root, "dummy_rank_f_zero", "F 0%", 20);
|
|
}
|
|
|
|
private static void AddLocalForcedRankDummy(List<SongInfo> songs, string root, string id, string title, int noteCount)
|
|
{
|
|
if (songs.Exists(song => song.id == id))
|
|
return;
|
|
|
|
string songDir = Path.Combine(root, id);
|
|
string audioPath = Path.Combine(songDir, $"{id}.mp3");
|
|
string mapFile = $"Map_{id}_forced.json";
|
|
string mapPath = Path.Combine(songDir, mapFile);
|
|
if (!File.Exists(audioPath) || !File.Exists(mapPath))
|
|
return;
|
|
|
|
long audioSize = new FileInfo(audioPath).Length;
|
|
long mapSize = new FileInfo(mapPath).Length;
|
|
DifficultyInfo info = new DifficultyInfo
|
|
{
|
|
mapFile = mapFile,
|
|
mapSize = mapSize,
|
|
noteCount = noteCount
|
|
};
|
|
|
|
songs.Insert(0, new SongInfo
|
|
{
|
|
id = id,
|
|
title = title,
|
|
artist = "Forced Rank Dummy",
|
|
bpm = 120.0f,
|
|
duration = 1,
|
|
audioFile = $"dummy/{id}.mp3",
|
|
audioSize = audioSize,
|
|
coverImage = "",
|
|
difficulties = new DifficultyMap
|
|
{
|
|
normal = info,
|
|
hard = info,
|
|
expert = info,
|
|
expertplus = info
|
|
},
|
|
addedAt = "2026-05-29"
|
|
});
|
|
}
|
|
}
|