存档重构,游戏内容解锁机制;教程完善(未完成)
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user