feat: SongSelect UI polish — marquee title, button states, font

- MarqueeText: scrolling title for long song names (RectMask2D clipped)
- SongDetailPanel: difficulty selected = green (img.color direct set)
- SongSelectManager: ALL/OWNED tab active state via img.color
- Card layout: DestroyImmediate + correct rebuild order to fix zero-size title bug
- NanumGothic SDF fallback configured on LiberationSans for Korean support

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 13:31:04 +09:00
parent 1f1100bbd8
commit 64ef3d64ec
7 changed files with 3478 additions and 3415 deletions
+55
View File
@@ -0,0 +1,55 @@
using System.Collections;
using TMPro;
using UnityEngine;
[RequireComponent(typeof(TMP_Text))]
public class MarqueeText : MonoBehaviour
{
public float speed = 35f;
public float pauseStart = 1.5f;
public float pauseEnd = 0.6f;
private TMP_Text _label;
private RectTransform _rect;
private void Awake()
{
_label = GetComponent<TMP_Text>();
_rect = GetComponent<RectTransform>();
}
private IEnumerator Start()
{
yield return null; // layout 완료 후 실행
_label.ForceMeshUpdate();
float textW = _label.preferredWidth;
float containerW = ((RectTransform)transform.parent).rect.width;
float dist = textW - containerW;
if (dist > 1f)
StartCoroutine(ScrollLoop(dist));
}
private IEnumerator ScrollLoop(float dist)
{
while (true)
{
SetX(0f);
yield return new WaitForSeconds(pauseStart);
float x = 0f;
while (x > -dist)
{
x = Mathf.MoveTowards(x, -dist, speed * Time.deltaTime);
SetX(x);
yield return null;
}
yield return new WaitForSeconds(pauseEnd);
}
}
private void SetX(float x) =>
_rect.anchoredPosition = new Vector2(x, _rect.anchoredPosition.y);
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: aec41476b82385047a8cec63612a6698
+11 -4
View File
@@ -34,7 +34,7 @@ public class SongDetailPanel : MonoBehaviour
[SerializeField] private string gameSceneName = "Game"; [SerializeField] private string gameSceneName = "Game";
private static readonly Color SelectedColor = new Color(0.2f, 0.78f, 0.4f); private static readonly Color SelectedColor = new Color(0.2f, 0.78f, 0.4f);
private static readonly Color DeselectedColor = Color.white; private static readonly Color DeselectedImgColor = new Color(1f, 1f, 1f, 0.12f); // original button alpha
private SongInfo currentSong; private SongInfo currentSong;
private string selectedDifficulty; private string selectedDifficulty;
@@ -123,9 +123,16 @@ public class SongDetailPanel : MonoBehaviour
foreach (var (key, getBtn) in diffSlots) foreach (var (key, getBtn) in diffSlots)
{ {
Button btn = getBtn(this); Button btn = getBtn(this);
var colors = btn.colors; bool selected = key == selectedDifficulty;
colors.normalColor = (key == selectedDifficulty) ? SelectedColor : DeselectedColor;
btn.colors = colors; if (btn.targetGraphic is Image img)
img.color = selected ? SelectedColor : DeselectedImgColor;
var cb = btn.colors;
cb.normalColor = Color.white;
cb.highlightedColor = selected ? new Color(0.3f, 0.95f, 0.55f) : new Color(1f, 1f, 1f, 0.25f);
cb.pressedColor = selected ? new Color(0.15f, 0.6f, 0.3f) : new Color(1f, 1f, 1f, 0.35f);
btn.colors = cb;
} }
} }
+39 -14
View File
@@ -14,19 +14,25 @@ public class SongSelectManager : MonoBehaviour
[SerializeField] private GameObject loadingOverlay; [SerializeField] private GameObject loadingOverlay;
[SerializeField] private GameObject errorOverlay; [SerializeField] private GameObject errorOverlay;
[SerializeField] private TMP_Text errorText; [SerializeField] private TMP_Text errorText;
[SerializeField] private TMP_FontAsset cardFont;
private static readonly Color TabActive = Color.white;
private static readonly Color TabInactive = new Color(0.6f, 0.6f, 0.6f); private static readonly Color TabActive = new Color(1f, 1f, 1f, 0.45f);
private static readonly Color TabInactive = new Color(1f, 1f, 1f, 0.12f);
private static string CachePath => private static string CachePath =>
Path.Combine(Application.persistentDataPath, "songs_cache.json"); Path.Combine(Application.persistentDataPath, "songs_cache.json");
private List<SongInfo> allSongs = new List<SongInfo>(); private List<SongInfo> allSongs = new List<SongInfo>();
private bool showingOwned = false; private bool showingOwned = false;
private TMP_FontAsset _cardFont;
private void Start() 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)); tabAllBtn .onClick.AddListener(() => SwitchTab(false));
tabOwnedBtn.onClick.AddListener(() => SwitchTab(true)); tabOwnedBtn.onClick.AddListener(() => SwitchTab(true));
detailPanel.gameObject.SetActive(false); detailPanel.gameObject.SetActive(false);
@@ -49,8 +55,10 @@ public class SongSelectManager : MonoBehaviour
private static void ApplyTabColor(Button btn, Color c) private static void ApplyTabColor(Button btn, Color c)
{ {
if (btn.targetGraphic is Image img)
img.color = c;
var colors = btn.colors; var colors = btn.colors;
colors.normalColor = c; colors.normalColor = Color.white;
btn.colors = colors; btn.colors = colors;
} }
@@ -87,8 +95,9 @@ public class SongSelectManager : MonoBehaviour
public void RefreshCards() public void RefreshCards()
{ {
// DestroyImmediate to avoid deferred-destroy interfering with layout
for (int i = cardContainer.childCount - 1; i >= 0; i--) for (int i = cardContainer.childCount - 1; i >= 0; i--)
Destroy(cardContainer.GetChild(i).gameObject); DestroyImmediate(cardContainer.GetChild(i).gameObject);
List<SongInfo> songs = showingOwned List<SongInfo> songs = showingOwned
? allSongs.FindAll(s => SongLibrary.Instance.IsSongDownloaded(s.id)) ? allSongs.FindAll(s => SongLibrary.Instance.IsSongDownloaded(s.id))
@@ -96,6 +105,10 @@ public class SongSelectManager : MonoBehaviour
foreach (SongInfo song in songs) foreach (SongInfo song in songs)
SpawnCard(song); 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) private void SpawnCard(SongInfo song)
@@ -121,21 +134,33 @@ public class SongSelectManager : MonoBehaviour
bc.fadeDuration = 0.1f; bc.fadeDuration = 0.1f;
btn.colors = bc; btn.colors = bc;
// Title // 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(5f, 0f);
tmr.offsetMax = new Vector2(downloaded ? -20f : -3f, 0f);
titleMask.AddComponent<RectMask2D>();
var titleGO = new GameObject("Title"); var titleGO = new GameObject("Title");
titleGO.transform.SetParent(card.transform, false); titleGO.transform.SetParent(titleMask.transform, false);
var tr = titleGO.AddComponent<RectTransform>(); var tr = titleGO.AddComponent<RectTransform>();
tr.anchorMin = new Vector2(0f, 0.5f); tr.anchorMin = new Vector2(0f, 0f);
tr.anchorMax = new Vector2(1f, 1f); tr.anchorMax = new Vector2(0f, 1f);
tr.offsetMin = new Vector2(5f, 0f); tr.pivot = new Vector2(0f, 0.5f);
tr.offsetMax = new Vector2(downloaded ? -18f : -3f, -1f); tr.anchoredPosition = Vector2.zero;
tr.sizeDelta = new Vector2(500f, 0f);
var tTmp = titleGO.AddComponent<TextMeshProUGUI>(); var tTmp = titleGO.AddComponent<TextMeshProUGUI>();
if (cardFont != null) tTmp.font = cardFont; if (_cardFont != null) tTmp.font = _cardFont;
tTmp.text = song.title; tTmp.text = song.title;
tTmp.fontSize = 5f; tTmp.fontSize = 5f;
tTmp.color = Color.white; tTmp.color = Color.white;
tTmp.alignment = TextAlignmentOptions.MidlineLeft; tTmp.alignment = TextAlignmentOptions.MidlineLeft;
tTmp.overflowMode = TextOverflowModes.Ellipsis; tTmp.overflowMode = TextOverflowModes.Overflow;
tTmp.enableWordWrapping = false;
titleGO.AddComponent<MarqueeText>();
// Artist // Artist
var artistGO = new GameObject("Artist"); var artistGO = new GameObject("Artist");
@@ -146,7 +171,7 @@ public class SongSelectManager : MonoBehaviour
ar.offsetMin = new Vector2(5f, 1f); ar.offsetMin = new Vector2(5f, 1f);
ar.offsetMax = new Vector2(-3f, 0f); ar.offsetMax = new Vector2(-3f, 0f);
var aTmp = artistGO.AddComponent<TextMeshProUGUI>(); var aTmp = artistGO.AddComponent<TextMeshProUGUI>();
if (cardFont != null) aTmp.font = cardFont; if (_cardFont != null) aTmp.font = _cardFont;
aTmp.text = song.artist; aTmp.text = song.artist;
aTmp.fontSize = 4f; aTmp.fontSize = 4f;
aTmp.color = new Color(1f, 1f, 1f, 0.6f); aTmp.color = new Color(1f, 1f, 1f, 0.6f);
@@ -257917,7 +257917,8 @@ MonoBehaviour:
m_MarkToBaseAdjustmentRecords: [] m_MarkToBaseAdjustmentRecords: []
m_MarkToMarkAdjustmentRecords: [] m_MarkToMarkAdjustmentRecords: []
m_ShouldReimportFontFeatures: 0 m_ShouldReimportFontFeatures: 0
m_FallbackFontAssetTable: [] m_FallbackFontAssetTable:
- {fileID: 11400000}
m_FontWeightTable: m_FontWeightTable:
- regularTypeface: {fileID: 0} - regularTypeface: {fileID: 0}
italicTypeface: {fileID: 0} italicTypeface: {fileID: 0}
@@ -258046,9 +258047,9 @@ Material:
- _OutlineWidth: 0 - _OutlineWidth: 0
- _PerspectiveFilter: 0.875 - _PerspectiveFilter: 0.875
- _Reflectivity: 10 - _Reflectivity: 10
- _ScaleRatioA: 1 - _ScaleRatioA: 0
- _ScaleRatioB: 1 - _ScaleRatioB: 0
- _ScaleRatioC: 1 - _ScaleRatioC: 0
- _ScaleX: 1 - _ScaleX: 1
- _ScaleY: 1 - _ScaleY: 1
- _ShaderFlags: 0 - _ShaderFlags: 0
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff