2026-07-20 16:56:04 -04:00
|
|
|
|
using Ichni;
|
|
|
|
|
|
using Ichni.Menu;
|
2026-07-05 16:08:23 -04:00
|
|
|
|
using TMPro;
|
|
|
|
|
|
using UnityEngine;
|
2026-07-20 16:56:04 -04:00
|
|
|
|
using UnityEngine.UI;
|
2026-07-05 16:08:23 -04:00
|
|
|
|
|
|
|
|
|
|
namespace Ichni.Story.UI
|
|
|
|
|
|
{
|
|
|
|
|
|
/// <summary>
|
2026-07-20 16:56:04 -04:00
|
|
|
|
/// 剧情树中的歌曲 Block 视图。
|
|
|
|
|
|
/// 它只保存 <see cref="StoryBlockDefinition.songName"/> 这一个稳定歌曲标识;歌曲名称、作曲者与插画
|
|
|
|
|
|
/// 均从当前章节的 <see cref="SongItemData"/> 读取,避免 StoryData 与选曲资料出现两份可漂移的数据。
|
|
|
|
|
|
/// 点击后切换到选曲并自动选中对应歌曲的流程将在后续阶段接入。
|
2026-07-05 16:08:23 -04:00
|
|
|
|
/// </summary>
|
|
|
|
|
|
public class SongBlockView : StoryBlockView
|
|
|
|
|
|
{
|
|
|
|
|
|
[Header("Song Block")]
|
2026-07-20 16:56:04 -04:00
|
|
|
|
[Tooltip("Mask 下的 16:9 插画 Image。Sprite 由当前章节的 SongItemData.illustration 提供。")]
|
|
|
|
|
|
public Image illustrationImage;
|
|
|
|
|
|
|
|
|
|
|
|
[Tooltip("illustrationImage 对应的 RectTransform。仅修改其 anchoredPosition.y 以完成纵向裁切。")]
|
|
|
|
|
|
public RectTransform illustrationRect;
|
|
|
|
|
|
|
2026-07-05 16:08:23 -04:00
|
|
|
|
public TMP_Text songNameText;
|
2026-07-20 16:56:04 -04:00
|
|
|
|
public TMP_Text composerText;
|
|
|
|
|
|
|
|
|
|
|
|
[Tooltip("歌曲或剧情 Block 不可进入时显示的遮罩节点。")]
|
|
|
|
|
|
public GameObject lockOverlay;
|
|
|
|
|
|
|
|
|
|
|
|
private ChapterSelectionUnit _chapter;
|
|
|
|
|
|
private SongItemData _song;
|
|
|
|
|
|
private bool _canEnterSong;
|
2026-07-05 16:08:23 -04:00
|
|
|
|
|
|
|
|
|
|
public override void Initialize(StoryBlockDefinition def, Vector2 position, StoryBlockState blockState)
|
|
|
|
|
|
{
|
|
|
|
|
|
base.Initialize(def, position, blockState);
|
|
|
|
|
|
|
2026-07-20 16:56:04 -04:00
|
|
|
|
if (TryResolveSong(out _chapter, out _song))
|
|
|
|
|
|
{
|
|
|
|
|
|
illustrationImage.sprite = _song.illustration;
|
|
|
|
|
|
songNameText.text = _song.displaySongName;
|
|
|
|
|
|
composerText.text = _song.composer;
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
// 配置错误时保留稳定 ID 以便定位问题,同时锁定 Block,避免从剧情页绕过内容授权。
|
|
|
|
|
|
illustrationImage.sprite = null;
|
2026-07-05 16:08:23 -04:00
|
|
|
|
songNameText.text = def.songName;
|
2026-07-20 16:56:04 -04:00
|
|
|
|
composerText.text = string.Empty;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 数据存储的是最终 local Y,而非叠加在 Prefab 默认位置上的偏移量。
|
|
|
|
|
|
// Clamp 仍保留作为运行时防线:即使旧资产或外部工具写入越界值,也不会在 Mask 边缘露出空白。
|
|
|
|
|
|
Vector2 illustrationPosition = illustrationRect.anchoredPosition;
|
|
|
|
|
|
illustrationPosition.y = Mathf.Clamp(def.illustrationLocalY, -74.25f, 74.25f);
|
|
|
|
|
|
illustrationRect.anchoredPosition = illustrationPosition;
|
|
|
|
|
|
|
|
|
|
|
|
ApplyState(blockState);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// StoryBlock 的剧情状态和歌曲的内容解锁状态必须同时满足,才能真正点击进入歌曲。
|
|
|
|
|
|
/// <see cref="UnlockSaveModule.CanEnterSong"/> 复用选曲页面的统一授权判断,防止 Story SongBlock 成为绕过入口。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public override void ApplyState(StoryBlockState newState)
|
|
|
|
|
|
{
|
|
|
|
|
|
base.ApplyState(newState);
|
|
|
|
|
|
|
|
|
|
|
|
bool storyAllowsInteraction = state != StoryBlockState.Locked && state != StoryBlockState.Forbidden;
|
|
|
|
|
|
_canEnterSong = _chapter != null && _song != null &&
|
|
|
|
|
|
GameSaveManager.instance != null &&
|
|
|
|
|
|
GameSaveManager.instance.UnlockSaveModule != null &&
|
|
|
|
|
|
GameSaveManager.instance.UnlockSaveModule.CanEnterSong(_chapter, _song);
|
|
|
|
|
|
|
|
|
|
|
|
bool canInteract = storyAllowsInteraction && _canEnterSong;
|
|
|
|
|
|
button.interactable = canInteract;
|
|
|
|
|
|
lockOverlay.SetActive(!canInteract);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 根据正在构建的剧情章节索引定位歌曲,而不是盲目信任 currentChapter。
|
|
|
|
|
|
/// 这样即使未来从其它页面直接打开 StoryData,也不会把同名歌曲解析到上一次残留的章节中。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private bool TryResolveSong(out ChapterSelectionUnit chapter, out SongItemData song)
|
|
|
|
|
|
{
|
|
|
|
|
|
chapter = null;
|
|
|
|
|
|
song = null;
|
|
|
|
|
|
|
|
|
|
|
|
string chapterIndex = StoryManager.instance?.treeController?.ActiveChapterIndex;
|
|
|
|
|
|
ChapterSelectionManager chapterManager = ChapterSelectionManager.instance;
|
|
|
|
|
|
if (string.IsNullOrEmpty(chapterIndex) || chapterManager == null)
|
|
|
|
|
|
{
|
|
|
|
|
|
Debug.LogWarning($"[SongBlockView] 无法解析歌曲 Block '{blockId}':当前章节或 ChapterSelectionManager 不可用。");
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
foreach (ChapterSelectionUnit candidate in chapterManager.chapters)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (candidate != null && candidate.chapterIndex == chapterIndex)
|
|
|
|
|
|
{
|
|
|
|
|
|
chapter = candidate;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (chapter == null)
|
|
|
|
|
|
{
|
|
|
|
|
|
Debug.LogWarning($"[SongBlockView] 无法解析歌曲 Block '{blockId}':找不到章节 '{chapterIndex}' 的 ChapterSelectionUnit。");
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
foreach (SongItemData candidate in chapter.songs)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (candidate != null && candidate.songName == definition.songName)
|
|
|
|
|
|
{
|
|
|
|
|
|
song = candidate;
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Debug.LogWarning($"[SongBlockView] 无法解析歌曲 Block '{blockId}':章节 '{chapterIndex}' 中不存在歌曲 ID '{definition.songName}'。");
|
|
|
|
|
|
return false;
|
2026-07-05 16:08:23 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
protected override void OnClicked()
|
|
|
|
|
|
{
|
2026-07-20 16:56:04 -04:00
|
|
|
|
if (!_canEnterSong)
|
|
|
|
|
|
{
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-05 16:08:23 -04:00
|
|
|
|
// 占位:后续接入曲目选择流程
|
|
|
|
|
|
Debug.Log($"[SongBlockView] 点击音乐块 '{blockId}' (song='{definition.songName}')。占位实现,曲目选择将在后续阶段接入。");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|