存档重构,游戏内容解锁机制;教程完善(未完成)

This commit is contained in:
SoulliesOfficial
2026-07-18 16:51:18 -04:00
parent d48ef1e65e
commit dda354ebb9
123 changed files with 4032 additions and 558 deletions

View File

@@ -24,6 +24,11 @@ namespace Ichni.UI
public ChapterSelectionUnit connectedChapter;
public string chapterName;
/// <summary>
/// 当前章节的运行时锁定状态。仅用于 UI 表现;真正进入剧情或选曲前仍会重新调用
/// <see cref="UnlockSaveModule.CanAccessChapter"/>,不能把本字段作为授权依据。
/// </summary>
public bool isLocked;
public bool isExpanded;
public bool isDuringAnimation;
@@ -59,6 +64,7 @@ namespace Ichni.UI
{
connectedChapter = chapter;
expansionRipple.material = new Material(rippleMaterial);
RefreshUnlockState();
expandButton.onClick.AddListener(() =>
{
@@ -83,6 +89,11 @@ namespace Ichni.UI
{
return;
}
if (!CanEnterChapter())
{
return;
}
ChapterSelectionManager.instance.currentChapter = connectedChapter;
AudioManager.SetSwitch(connectedChapter.chapterSwitch);
@@ -97,6 +108,11 @@ namespace Ichni.UI
{
return;
}
if (!CanEnterChapter())
{
return;
}
ChapterSelectionManager.instance.currentChapter = connectedChapter;
AudioManager.SetSwitch(connectedChapter.chapterSwitch);
@@ -112,6 +128,36 @@ namespace Ichni.UI
allPerfectText.text = allPerfectCount.ToString();
beatmapProgressText.text = $"{finishedBeatmapCount}/{beatmapCount}";
}
/// <summary>
/// 根据统一内容解锁服务刷新章节入口的可交互状态。
/// 当前 Prefab 尚无专用锁定美术,因此先禁用两个实际入口;后续补充锁图标时,
/// 只需读取 <see cref="isLocked"/>,无需复制解锁判断。
/// </summary>
private void RefreshUnlockState()
{
UnlockSaveModule unlockSaveModule = GameSaveManager.instance?.UnlockSaveModule;
isLocked = unlockSaveModule == null || !unlockSaveModule.CanAccessChapter(connectedChapter);
if (enterStorylineButton != null)
{
enterStorylineButton.interactable = !isLocked;
}
if (enterSongSelectionButton != null)
{
enterSongSelectionButton.interactable = !isLocked;
}
}
/// <summary>
/// 章节的最终入口检查。即使 UI Button 状态被其它脚本意外恢复,也不能绕过解锁规则。
/// </summary>
private bool CanEnterChapter()
{
RefreshUnlockState();
return !isLocked;
}
private void Expand()
{
@@ -197,4 +243,4 @@ namespace Ichni.UI
shrinkSequence.Play();
}
}
}
}

View File

@@ -0,0 +1,114 @@
using System;
using System.Collections.Generic;
using DG.Tweening;
using TMPro;
using UnityEngine;
using UnityEngine.Events;
namespace Ichni.Menu.UI
{
/// <summary>
/// 可复用的多选弹窗元素。调用方提供标题、内容与任意数量的 <see cref="SelectionBoxOption"/>
/// 本类动态实例化对应按钮,并确保一次显示最多只会执行一个选项回调。
/// </summary>
public class SelectionBox : MonoBehaviour
{
[Header("Selection Box")]
public CanvasGroup canvasGroup;
public TMP_Text titleText;
public TMP_Text contentText;
public RectTransform optionContainer;
public SelectionBoxButton optionButtonPrefab;
private readonly List<SelectionBoxButton> _generatedButtons = new();
private bool _isResolved;
/// <summary>
/// 选项被选择并且自身淡出结束后触发。页面容器使用它销毁本次生成的 SelectionBox。
/// </summary>
public event Action<SelectionBox> Closed;
private void Awake()
{
canvasGroup ??= GetComponent<CanvasGroup>();
}
/// <summary>
/// 根据运行时选项初始化弹窗。至少需要一个选项;失败时返回 false且不会显示半配置的弹窗。
/// </summary>
public bool SetUp(string title, string content, IReadOnlyList<SelectionBoxOption> options)
{
if (canvasGroup == null || optionContainer == null || optionButtonPrefab == null ||
options == null || options.Count == 0)
{
Debug.LogWarning("[SelectionBox] 缺少 CanvasGroup、选项容器、选项按钮预制体或未提供任何选项。");
return false;
}
ClearGeneratedButtons();
_isResolved = false;
if (titleText != null) titleText.text = title ?? string.Empty;
if (contentText != null) contentText.text = content ?? string.Empty;
foreach (SelectionBoxOption option in options)
{
SelectionBoxButton button = Instantiate(optionButtonPrefab, optionContainer);
_generatedButtons.Add(button);
button.SetUp(option.label, () => Select(option.action));
}
gameObject.SetActive(true);
canvasGroup.alpha = 0f;
canvasGroup.interactable = true;
canvasGroup.blocksRaycasts = true;
canvasGroup.DOFade(1f, 0.2f).Play();
return true;
}
private void Select(UnityAction action)
{
if (_isResolved) return;
_isResolved = true;
// 先禁用所有输入,再执行回调。回调可以触发存档、场景切换或打开下一层 UI
// 因而不能让同一帧内的其它按钮再次触发。
canvasGroup.interactable = false;
canvasGroup.blocksRaycasts = false;
foreach (SelectionBoxButton button in _generatedButtons)
{
if (button?.button != null)
button.button.interactable = false;
}
action?.Invoke();
canvasGroup.DOFade(0f, 0.2f).OnComplete(() => Closed?.Invoke(this)).Play();
}
private void ClearGeneratedButtons()
{
foreach (SelectionBoxButton button in _generatedButtons)
{
if (button != null)
Destroy(button.gameObject);
}
_generatedButtons.Clear();
}
}
/// <summary>
/// SelectionBox 的单个运行时选项。它不序列化到场景或存档;显示时由调用方临时创建。
/// </summary>
public readonly struct SelectionBoxOption
{
public readonly string label;
public readonly UnityAction action;
public SelectionBoxOption(string label, UnityAction action)
{
this.label = label;
this.action = action;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5ac6b73d890a4a7aaed8e2fa64001764

View File

@@ -0,0 +1,40 @@
using TMPro;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
namespace Ichni.Menu.UI
{
/// <summary>
/// SelectionBox 动态生成的单个选项按钮。
/// 预制体只需提供 Button 和 TMP 文本;运行时由 <see cref="SelectionBox"/> 写入显示文字与点击回调。
/// </summary>
public class SelectionBoxButton : MonoBehaviour
{
public Button button;
public TMP_Text labelText;
/// <summary>
/// 重新绑定按钮文字与本次 SelectionBox 的选择回调。
/// 每次生成时都会清空旧监听,防止对象复用或预制体事件造成重复触发。
/// </summary>
public void SetUp(string label, UnityAction onSelected)
{
button ??= GetComponent<Button>();
labelText ??= GetComponentInChildren<TMP_Text>(true);
if (labelText != null)
labelText.text = label ?? string.Empty;
if (button == null)
{
Debug.LogWarning("[SelectionBoxButton] 选项预制体缺少 Button 组件。");
return;
}
button.onClick.RemoveAllListeners();
button.onClick.AddListener(() => onSelected?.Invoke());
button.interactable = true;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b2c45ae5390d4a4aa16e62b5704dbf0a

View File

@@ -0,0 +1,73 @@
using System.Collections.Generic;
using Ichni.UI;
using UnityEngine;
namespace Ichni.Menu.UI
{
/// <summary>
/// 通用 SelectionBox 的页面宿主,职责等同于 MessageBox 的页面容器:
/// 显示时动态生成一个 SelectionBox选择完成后统一淡出并销毁本次实例。
/// </summary>
public class SelectionBoxUIPage : UIPageBase
{
[Header("Selection Box Page")]
public SelectionBox selectionBoxPrefab;
public RectTransform selectionBoxContainer;
private SelectionBox _currentSelectionBox;
private bool _isShowing;
/// <summary>当前是否已有一个 SelectionBox 等待玩家选择。</summary>
public bool IsShowing => _isShowing;
/// <summary>
/// 生成并显示一组运行时选项。页面显示期间拒绝重复请求,避免两个弹窗争夺输入。
/// </summary>
public bool Show(string title, string content, IReadOnlyList<SelectionBoxOption> options)
{
if (_isShowing)
{
Debug.LogWarning("[SelectionBoxUIPage] 已有 SelectionBox 显示中,忽略重复请求。");
return false;
}
if (mainCanvasGroup == null || selectionBoxPrefab == null || selectionBoxContainer == null)
{
Debug.LogWarning("[SelectionBoxUIPage] 缺少 CanvasGroup、SelectionBox Prefab 或容器,无法显示选项框。");
return false;
}
_currentSelectionBox = Instantiate(selectionBoxPrefab, selectionBoxContainer);
_currentSelectionBox.Closed += HandleSelectionBoxClosed;
if (!_currentSelectionBox.SetUp(title, content, options))
{
Destroy(_currentSelectionBox.gameObject);
_currentSelectionBox = null;
return false;
}
_isShowing = true;
// UIPageBase 默认淡入完成后才开启射线;选择框从出现瞬间就必须阻断底层输入。
mainCanvasGroup.gameObject.SetActive(true);
mainCanvasGroup.interactable = true;
mainCanvasGroup.blocksRaycasts = true;
FadeIn();
return true;
}
private void HandleSelectionBoxClosed(SelectionBox selectionBox)
{
if (selectionBox != _currentSelectionBox) return;
FadeOut(0.2f, false, () =>
{
if (_currentSelectionBox != null)
Destroy(_currentSelectionBox.gameObject);
_currentSelectionBox = null;
_isShowing = false;
});
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e8b607f8c5654e1eb3bf1f7ca33fd3a4

View File

@@ -3,6 +3,7 @@ using System.Collections;
using System.Collections.Generic;
using System.Linq;
using DG.Tweening;
using Ichni.RhythmGame;
using Sirenix.OdinInspector;
using SLSUtilities.WwiseAssistance;
using TMPro;
@@ -53,11 +54,21 @@ namespace Ichni.Menu
{
MenuManager.instance.songSelectionUIPage.selectedDifficulty = difficultyData;
MenuManager.instance.songSelectionUIPage.songInfoUI.SetCharter(difficultyData.charterName);
MenuManager.instance.songSelectionUIPage.songInfoUI.SetBeatmapInfo(
MenuManager.instance.songSelectionUIPage.selectedSave.beatmapSaves[difficultyData.difficultyIndex]);
if (MenuManager.instance.songSelectionUIPage.selectedSave.TryGetBeatmapSave(
difficultyData.saveDifficultyId, out BeatmapSave beatmapSave))
{
MenuManager.instance.songSelectionUIPage.songInfoUI.SetBeatmapInfo(beatmapSave);
}
else
{
// 当前内容尚未创建该难度的记录时UI 只显示空成绩,不在展示路径修改存档。
MenuManager.instance.songSelectionUIPage.songInfoUI.SetBeatmapInfo(new BeatmapSave(0f, false, false));
}
MenuInformationRecorder.instance.songSelectionRecords[ChapterSelectionManager.instance.currentChapter] =
new SongSelectionRecord(MenuManager.instance.songSelectionUIPage.selectedSong, difficultyData);
MenuInformationRecorder.instance.SetRecordForChapter(
ChapterSelectionManager.instance.currentChapter,
MenuManager.instance.songSelectionUIPage.selectedSong,
MenuManager.instance.songSelectionUIPage.selectedSong.difficultyDataList.IndexOf(difficultyData));
if (container.selectedButton == this)
{
@@ -67,7 +78,6 @@ namespace Ichni.Menu
MenuManager.instance.songSelectionUIPage.difficultySelectionContainer.selectedButton?.Deselect();
MenuManager.instance.songSelectionUIPage.difficultySelectionContainer.selectedButton = this;
MenuInformationRecorder.instance.GetRecordOfThisChapter().difficultyIndex = difficultyData.difficultyIndex;
AudioManager.Post(AK.EVENTS.SELECTDIFFICULTY);

View File

@@ -1,5 +1,5 @@
using System.Collections;
using System.Collections.Generic;
using System;
using UnityEngine;
namespace Ichni.Menu
@@ -9,28 +9,123 @@ namespace Ichni.Menu
public List<DifficultySelectionButton> buttons;
public DifficultySelectionButton selectedButton;
/// <summary>
/// 在当前歌曲中解析一个可实际游玩的难度。
/// <para>preferredListIndex 是 <see cref="SongSelectionRecord.difficultyListIndex"/>,即难度在当前歌曲
/// List 中的位置;它不能替代 <see cref="DifficultyData.saveDifficultyId"/>,后者是游戏记录的稳定 ID。</para>
/// <para>规则:只考虑已配置 Button 且 <c>isAvailable</c> 为 true 的难度,优先选择与目标位置距离最小者;
/// 距离相同则选择位置较低者。因此目标难度不存在时,玩家会落到最接近、且偏低的可用难度。</para>
/// </summary>
private bool TryResolveAvailableDifficultyIndex(
IReadOnlyList<DifficultyData> difficulties,
int preferredListIndex,
out int resolvedListIndex)
{
resolvedListIndex = -1;
if (difficulties == null || buttons == null)
{
return false;
}
int candidateCount = Mathf.Min(difficulties.Count, buttons.Count);
long bestDistance = long.MaxValue;
for (int index = 0; index < candidateCount; index++)
{
DifficultyData difficulty = difficulties[index];
if (difficulty == null || !difficulty.isAvailable || buttons[index] == null)
{
continue;
}
long distance = Math.Abs((long)index - preferredListIndex);
if (distance < bestDistance || (distance == bestDistance && index < resolvedListIndex))
{
bestDistance = distance;
resolvedListIndex = index;
}
}
return resolvedListIndex >= 0;
}
/// <summary>
/// 清除当前的难度选择,并同时关闭标准的进入游戏按钮。
/// SongSelectionTab 的快速进入入口也会单独检查 <c>selectedDifficulty</c>,避免绕过此 UI 状态。
/// </summary>
private void ClearDifficultySelection()
{
selectedButton?.Deselect();
selectedButton = null;
SongSelectionUIPage songSelectionUIPage = MenuManager.instance.songSelectionUIPage;
songSelectionUIPage.selectedDifficulty = null;
songSelectionUIPage.playSongUI.SetEnterGameAvailable(false);
}
/// <summary>
/// 根据当前歌曲刷新难度按钮,并将章节内存缓存解析为一个实际可玩的难度。
/// 该方法用于首次进入、从游戏返回以及拖动切歌,所以所有自动难度回退都必须经过此处。
/// </summary>
public void SetUp(List<DifficultyData> difficulties)
{
int difficultyCount = difficulties.Count;
int difficultyCount = difficulties?.Count ?? 0;
int buttonCount = buttons?.Count ?? 0;
for (var i = 0; i < buttons.Count; i++)
ClearDifficultySelection();
if (difficultyCount > buttonCount)
{
buttons[i].gameObject.SetActive(i < difficultyCount);
Debug.LogError($"Song '{MenuManager.instance.songSelectionUIPage.selectedSong?.songName}' has {difficultyCount} difficulties but only {buttonCount} difficulty buttons. " +
"Only difficulties with configured buttons can be selected.");
}
for (int i = 0; i < difficultyCount; i++)
for (int i = 0; i < buttonCount; i++)
{
buttons[i].SetUp(difficulties[i]);
DifficultySelectionButton button = buttons[i];
if (button == null)
{
Debug.LogError($"Difficulty button at index {i} is missing.");
continue;
}
bool hasDifficulty = i < difficultyCount && difficulties[i] != null;
button.gameObject.SetActive(hasDifficulty);
if (hasDifficulty)
{
// SetUp 会进一步按 isAvailable 隐藏不可用难度。
button.SetUp(difficulties[i]);
}
}
SongSelectionRecord songSelectionRecord = MenuInformationRecorder.instance.GetRecordOfThisChapter();
if (songSelectionRecord.difficultyIndex >= difficultyCount)
if (difficultyCount == 0)
{
songSelectionRecord.difficultyIndex = difficultyCount - 1;
Debug.LogWarning($"Song '{MenuManager.instance.songSelectionUIPage.selectedSong?.songName}' has no configured difficulties.");
return;
}
buttons[songSelectionRecord.difficultyIndex % buttons.Count].Select();
if (!MenuInformationRecorder.instance.TryGetRecordOfThisChapter(out SongSelectionRecord songSelectionRecord))
{
Debug.LogWarning("Cannot select a difficulty because the current chapter has no valid song selection record.");
return;
}
if (!TryResolveAvailableDifficultyIndex(difficulties, songSelectionRecord.difficultyListIndex, out int resolvedListIndex))
{
Debug.LogWarning($"Song '{MenuManager.instance.songSelectionUIPage.selectedSong?.songName}' has no available difficulty to select.");
return;
}
// Select 会把实际选中的难度写回章节缓存;之后继续切歌时,以这次真实选择作为新的偏好。
buttons[resolvedListIndex].Select();
// 难度可用不等于内容已解锁。保持按钮状态与统一授权服务一致,
// 而 PlaySongUI / 快速点击仍会在真正进入前再次检查,形成双层防御。
UnlockSaveModule unlockSaveModule = GameSaveManager.instance?.UnlockSaveModule;
bool canEnterSong = unlockSaveModule != null && unlockSaveModule.CanEnterSong(
ChapterSelectionManager.instance?.currentChapter,
MenuManager.instance.songSelectionUIPage.selectedSong);
MenuManager.instance.songSelectionUIPage.playSongUI.SetEnterGameAvailable(canEnterSong);
}
}
}
}

View File

@@ -19,12 +19,37 @@ namespace Ichni.Menu
enterGameButton.onClick.AddListener(EnterGame);
}
/// <summary>
/// 设置标准 Play 按钮是否允许进入游戏。
/// 当当前歌曲没有可用难度时由 DifficultySelectionContainer 关闭;歌曲锁定仍沿用原有的独立判断。
/// </summary>
public void SetEnterGameAvailable(bool isAvailable)
{
enterGameButton.interactable = isAvailable;
}
private void EnterGame()
{
if (MenuManager.instance.songSelectionUIPage.songListController.selectedTab.isLocked)
SongSelectionUIPage songSelectionUIPage = MenuManager.instance.songSelectionUIPage;
SongSelectionTab selectedTab = songSelectionUIPage.songListController.selectedTab;
// 即使 Button 的 interactable 状态被错误地恢复,也不能把空难度传给 InformationTransistor。
if (songSelectionUIPage.selectedSong == null || songSelectionUIPage.selectedDifficulty == null || selectedTab == null)
{
return;
}
// 不能只读取 Tab 的旧显示状态。解锁 Key 可能刚被剧情授予,或 UI 状态被其它逻辑改写;
// 标准 Play 与快速点击都会回到 UnlockSaveModule 的同一最终授权判断。
UnlockSaveModule unlockSaveModule = GameSaveManager.instance?.UnlockSaveModule;
if (unlockSaveModule == null || !unlockSaveModule.CanEnterSong(
ChapterSelectionManager.instance?.currentChapter,
songSelectionUIPage.selectedSong))
{
return;
}
selectedTab.RefreshUnlockState();
if (MenuManager.instance.isEnteringGame)
{
@@ -34,9 +59,9 @@ namespace Ichni.Menu
MenuManager.instance.isEnteringGame = true;
InformationTransistor.instance.SetInformation(
ChapterSelectionManager.instance.currentChapter,
MenuManager.instance.songSelectionUIPage.selectedSong,
MenuManager.instance.songSelectionUIPage.selectedDifficulty);
ChapterSelectionManager.instance.currentChapter,
songSelectionUIPage.selectedSong,
songSelectionUIPage.selectedDifficulty);
AudioManager.Post(AK.EVENTS.ENTERTOGAME);
SongSelectionManager.instance.StopPreviewSong();
@@ -61,4 +86,4 @@ namespace Ichni.Menu
arrowSeq.Play();
}
}
}
}

View File

@@ -51,24 +51,63 @@ namespace Ichni.Menu
public void InitializeList()
{
// FadeOut 通常已经清理过旧 Tab但这里仍保持幂等防止重复初始化时残留旧引用或重复 UI。
ClearSongTabs();
GenerateSongTabs();
if (songItems.Count == 0)
{
// 空 Chapter 是内容异常;运行时只进入安全状态,不访问 songItems[0],也不允许进入游戏。
selectedTab = null;
closestTab = null;
topBound = content.anchoredPosition.y;
bottomBound = content.anchoredPosition.y;
targetPosition = content.anchoredPosition;
MenuManager.instance.songSelectionUIPage.selectedSong = null;
MenuManager.instance.songSelectionUIPage.selectedSave = null;
MenuManager.instance.songSelectionUIPage.difficultySelectionContainer.SetUp(null);
return;
}
Canvas.ForceUpdateCanvases();
topBound = (songItems.Count * 144f + (songItems.Count - 1) * 60f) - 72f; //topBound中144为tab高度60为tab间距72为tab高度的一半
bottomBound = 72f; //bottomBound中72为tab高度的一半
int songIndex = 0;
if (MenuInformationRecorder.instance.songSelectionRecords.TryGetValue(ChapterSelectionManager.instance.currentChapter, out var record))
int preferredDifficultyIndex = 0;
ChapterSelectionUnit currentChapter = ChapterSelectionManager.instance.currentChapter;
MenuInformationRecorder menuInformationRecorder = MenuInformationRecorder.instance;
if (menuInformationRecorder.TryGetRecordOfThisChapter(out SongSelectionRecord record))
{
songIndex = ChapterSelectionManager.instance.currentChapter.songs.FindIndex(song => song.songName == record.song.songName);
}
if (songItems.Count > 0)
{
StartCoroutine(SnapToItem(songItems[songIndex], true));
preferredDifficultyIndex = record.difficultyListIndex;
SongItemData cachedSong = record.song;
bool cacheNeedsRepair = false;
// 优先按对象引用恢复;若缓存来自内容热更新/重建,再按 songName 进行兼容匹配。
songIndex = songItems.FindIndex(item => item != null && item.GetComponent<SongSelectionTab>()?.connectedSong == cachedSong);
if (songIndex < 0 && !string.IsNullOrEmpty(cachedSong?.songName))
{
songIndex = songItems.FindIndex(item => item != null &&
item.GetComponent<SongSelectionTab>()?.connectedSong?.songName == cachedSong.songName);
cacheNeedsRepair = songIndex >= 0;
}
// 缓存歌曲已经被删除或改名时,立即修正缓存;难度由 SnapToItem 内的统一回退机制继续解析。
if (songIndex < 0)
{
songIndex = 0;
cacheNeedsRepair = true;
}
if (cacheNeedsRepair)
{
SongItemData resolvedSong = songItems[songIndex].GetComponent<SongSelectionTab>().connectedSong;
menuInformationRecorder.SetRecordForChapter(currentChapter, resolvedSong, preferredDifficultyIndex);
}
}
StartCoroutine(SnapToItem(songItems[songIndex], true));
closestTab = songItems[songIndex];
targetPosition = content.anchoredPosition;
@@ -181,13 +220,20 @@ namespace Ichni.Menu
{
public void ClearSongTabs()
{
StopAllCoroutines();
foreach (RectTransform item in songItems)
{
Destroy(item.gameObject);
if (item != null)
{
Destroy(item.gameObject);
}
}
songItems.Clear();
selectedTab = null;
closestTab = null;
velocity = Vector2.zero;
isDragging = false;
SnapCoroutine = null;
targetPosition = content.anchoredPosition;
isDuringSnap = false;
}
@@ -195,8 +241,19 @@ namespace Ichni.Menu
public void GenerateSongTabs()
{
ChapterSelectionUnit chapterUnit = ChapterSelectionManager.instance.currentChapter;
if (chapterUnit == null || chapterUnit.songs == null)
{
return;
}
foreach (SongItemData song in chapterUnit.songs)
{
if (song == null)
{
Debug.LogWarning("Skipped an empty song entry while generating song selection tabs.");
continue;
}
SongSelectionTab tab = Instantiate(songItemPrefab, content).GetComponent<SongSelectionTab>();
songItems.Add(tab.GetComponent<RectTransform>());
tab.SetUpTab(song);
@@ -330,4 +387,4 @@ namespace Ichni.Menu
isDuringSnap = false;
}
}
}
}

View File

@@ -16,6 +16,10 @@ namespace Ichni.Menu
{
private SongListControllerUI songListController => MenuManager.instance.songSelectionUIPage.songListController;
/// <summary>
/// 供锁标识和预览滤镜使用的运行时状态。进入歌曲前必须调用 <see cref="RefreshUnlockState"/>
/// 重新计算,不能把缓存的显示状态当作最终授权结果。
/// </summary>
public bool isLocked;
public SongItemData connectedSong;
@@ -38,13 +42,26 @@ namespace Ichni.Menu
songNameText.text = song.displaySongName;
composerNameText.text = song.composer;
isLocked = !GameSaveManager.instance.SongSaveModule.CheckStoryKey(song.storyUnlockKey);
lockMark.gameObject.SetActive(isLocked);
RefreshUnlockState();
quickSwitchButton.onClick.AddListener(() =>
{
if (MenuManager.instance.songSelectionUIPage.songListController.selectedTab == this)
{
SongSelectionUIPage songSelectionUIPage = MenuManager.instance.songSelectionUIPage;
// 快速进入会绕过 PlaySongUI 的 Button.interactable必须在此处重复保护空难度状态。
if (songSelectionUIPage.selectedSong == null || songSelectionUIPage.selectedDifficulty == null)
{
return;
}
// 快速进入与标准 Play 共用统一的章节 + 歌曲授权检查,不能因 UI 状态过期而绕过锁定。
if (!RefreshUnlockState())
{
return;
}
if (MenuManager.instance.isEnteringGame)
{
return;
@@ -53,9 +70,9 @@ namespace Ichni.Menu
MenuManager.instance.isEnteringGame = true;
InformationTransistor.instance.SetInformation(
ChapterSelectionManager.instance.currentChapter,
MenuManager.instance.songSelectionUIPage.selectedSong,
MenuManager.instance.songSelectionUIPage.selectedDifficulty);
ChapterSelectionManager.instance.currentChapter,
songSelectionUIPage.selectedSong,
songSelectionUIPage.selectedDifficulty);
AudioManager.Post(AK.EVENTS.ENTERTOGAME);
SongSelectionManager.instance.StopPreviewSong();
@@ -74,6 +91,25 @@ namespace Ichni.Menu
});
}
/// <summary>
/// 重新计算当前 Tab 是否锁定,并同步锁图标。
/// 章节本身被锁定时,即使歌曲规则无条件开放,也仍然视为不可进入。
/// </summary>
/// <returns>返回 true 表示当前章节和歌曲均允许进入。</returns>
public bool RefreshUnlockState()
{
UnlockSaveModule unlockSaveModule = GameSaveManager.instance?.UnlockSaveModule;
ChapterSelectionUnit currentChapter = ChapterSelectionManager.instance?.currentChapter;
isLocked = unlockSaveModule == null || !unlockSaveModule.CanEnterSong(currentChapter, connectedSong);
if (lockMark != null)
{
lockMark.gameObject.SetActive(isLocked);
}
return !isLocked;
}
private void Update()
{
RectTransform centerPoint = songListController.centerPoint;
@@ -113,4 +149,4 @@ namespace Ichni.Menu
}
}
}
}
}

View File

@@ -55,8 +55,6 @@ namespace Ichni.Menu
private void Start()
{
MenuInformationRecorder.instance.GetRecordOfThisChapter().difficultyIndex = 0;
Sequence framesSeq = DOTween.Sequence();
framesSeq.Append(DOTween.To(() => framePositionOffset, x => framePositionOffset = x, 1f, 1f)
.SetEase(Ease.Linear)
@@ -73,4 +71,4 @@ namespace Ichni.Menu
public DifficultyData selectedDifficulty;
public SongStatusSave selectedSave;
}
}
}