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

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

@@ -21,6 +21,14 @@ namespace Ichni.Menu
public Sprite avatar;
public Switch chapterSwitch;
/// <summary>
/// 章节剧情入口与选曲入口共用的内容解锁规则。
/// 留空表示章节从新档开始即可访问;需要锁定时请使用 <see cref="UnlockRequirement"/>
/// 并遵守 <see cref="UnlockSaveModule.KeyNamingRule"/> 中的下划线 Key 规则。
/// </summary>
[LabelText("Unlock Requirement")]
public UnlockRequirement unlockRequirement = new UnlockRequirement();
[Searchable]
public List<SongItemData> songs = new List<SongItemData>();
@@ -54,11 +62,6 @@ namespace Ichni.Menu
public partial class ChapterSelectionUnit
{
public List<string> GetRelatedSongNamesOfUnlockKey(string key)
{
return (from song in songs where song.storyUnlockKey == key select song.songName).ToList();
}
public (int, int, int, int, int) GetChapterSaveInfo()
{
int beatmapCount = 0;
@@ -72,9 +75,19 @@ namespace Ichni.Menu
if (GameSaveManager.instance.SongSaveModule.songStatusSaves.TryGetValue(song.songName, out var songStatus))
{
bool thisSongFinished = false;
foreach (BeatmapSave beatmapSave in songStatus.beatmapSaves)
foreach (DifficultyData difficulty in song.difficultyDataList)
{
if (difficulty == null || difficulty.saveDifficultyId < 0)
{
continue;
}
beatmapCount++;
if (!songStatus.TryGetBeatmapSave(difficulty.saveDifficultyId, out BeatmapSave beatmapSave))
{
continue;
}
if (!beatmapSave.IsEmpty())
{
thisSongFinished = true;
@@ -131,17 +144,24 @@ namespace Ichni.Menu
[FoldoutGroup("$songName")]
public List<DifficultyData> difficultyDataList;
/// <summary>
/// 歌曲自身的内容解锁规则。章节是否开放由 <see cref="ChapterSelectionUnit.unlockRequirement"/> 单独判断;
/// 真正进入歌曲时两者必须同时满足。
/// </summary>
[FoldoutGroup("$songName")]
public string storyUnlockKey;
[FoldoutGroup("$songName")]
public string paymentUnlockKey;
[LabelText("Unlock Requirement")]
public UnlockRequirement unlockRequirement = new UnlockRequirement();
}
[Serializable]
public class DifficultyData
{
public int difficultyIndex;
/// <summary>
/// 歌曲成绩的稳定 ID不等同于选曲 UI 的难度列表位置。
/// 发布后不要更改或复用既有 ID新增难度必须使用从未分配过的非负 ID。
/// </summary>
[FormerlySerializedAs("difficultyIndex")]
public int saveDifficultyId;
public string difficultyName;
public string displayDifficultyName;
public int difficultyValue;
@@ -149,14 +169,19 @@ namespace Ichni.Menu
public Color color;
public bool isAvailable;
public bool ForceFullScreenJudge = false;
[MinValue(1)]
[Tooltip("仅当 Note、Timing 或判定规则变化导致成绩不可与旧谱面比较时递增UI、文本、封面和音量调整不递增。")]
public int chartRevision = 1;
public DifficultyData()
{
}
public DifficultyData(int difficultyIndex, string difficultyName, string displayDifficultyName, int difficultyValue, string charterName, Color color, bool ForceFullScreenJudge = false)
public DifficultyData(int saveDifficultyId, string difficultyName, string displayDifficultyName, int difficultyValue, string charterName, Color color, bool ForceFullScreenJudge = false)
{
this.difficultyIndex = difficultyIndex;
this.saveDifficultyId = saveDifficultyId;
this.difficultyName = difficultyName;
this.displayDifficultyName = displayDifficultyName;
this.charterName = charterName;
@@ -170,5 +195,13 @@ namespace Ichni.Menu
{
return string.IsNullOrEmpty(displayDifficultyName) ? difficultyName : displayDifficultyName;
}
/// <summary>
/// 返回合法的 Revision。配置侧始终至少使用 10 只会被视为未初始化值并被钳制。
/// </summary>
public int GetChartRevision()
{
return Mathf.Max(1, chartRevision);
}
}
}

View File

@@ -7,12 +7,26 @@ using UnityEngine.Serialization;
namespace Ichni
{
/// <summary>
/// GameScene 返回 MenuScene 后应恢复的页面。
/// 该枚举替代互斥的布尔标记,避免普通歌曲与教程返回状态同时残留。
/// </summary>
public enum MenuReturnDestination
{
None,
SongSelection,
Story
}
public class InformationTransistor : MonoBehaviour
{
public static InformationTransistor instance;
public bool isReturnedFromGame;
public bool isReturnedFromTutorial;
/// <summary>
/// 当前游戏运行结束后MenuScene 应恢复的唯一目标页面。
/// 运行期间保存在 DontDestroyOnLoad 对象中,不写入玩家存档。
/// </summary>
public MenuReturnDestination menuReturnDestination;
public ChapterSelectionUnit chapter;
public SongItemData song;
@@ -30,8 +44,7 @@ namespace Ichni
{
instance = this;
DontDestroyOnLoad(gameObject);
isReturnedFromGame = false;
isReturnedFromTutorial = false;
menuReturnDestination = MenuReturnDestination.None;
}
else
{
@@ -42,11 +55,48 @@ namespace Ichni
public void SetInformation(ChapterSelectionUnit chapter,
SongItemData song, DifficultyData difficulty)
{
// 保留旧入口,供现有选曲流程在播放过场前预先写入歌曲信息。
// 真正进入 GameScene 时MenuManager.EnterGame 会补充 SongSelection 返回目标。
PrepareGameLaunch(chapter, song, difficulty, MenuReturnDestination.None);
}
/// <summary>
/// 统一准备一次 GameScene 运行所需的上下文。
/// 普通选曲和 TutorialBlock 都必须通过此入口写入,防止章节、歌曲、难度与返回目标来自不同旧运行。
/// </summary>
public bool PrepareGameLaunch(ChapterSelectionUnit chapter, SongItemData song,
DifficultyData difficulty, MenuReturnDestination returnDestination)
{
if (chapter == null || song == null || difficulty == null)
{
Debug.LogWarning("[InformationTransistor] 缺少章节、歌曲或难度,无法准备 GameScene 上下文。");
return false;
}
this.chapter = chapter;
this.song = song;
this.difficulty = difficulty;
this.chapterSwitch = chapter.chapterSwitch;
this.songSwitch = song.songSwitch;
chapterSwitch = chapter.chapterSwitch;
songSwitch = song.songSwitch;
menuReturnDestination = returnDestination;
return true;
}
/// <summary>
/// 由现有选曲过场在真正激活 GameScene 前设置返回目标。
/// </summary>
public void SetMenuReturnDestination(MenuReturnDestination returnDestination)
{
menuReturnDestination = returnDestination;
}
/// <summary>
/// MenuScene 已依据返回目标完成恢复后调用。
/// 只清除一次性路由,不清除歌曲与难度,以免当前 UI 的运行时缓存被意外破坏。
/// </summary>
public void ClearMenuReturnDestination()
{
menuReturnDestination = MenuReturnDestination.None;
}
}
}
}

View File

@@ -24,29 +24,70 @@ namespace Ichni.Menu
}
}
public SongSelectionRecord GetRecordOfThisChapter()
/// <summary>
/// 取得当前 Chapter 的选曲缓存;缓存缺失时创建“第一首有效歌曲 + 难度位置 0”的默认记录。
/// <para>此方法只保证缓存对象可用,不保证该歌曲或难度仍能游玩。歌曲失效与难度回退由选曲 UI 在
/// 每次进入页面时完成,避免把运行期缓存变成持久化存档。</para>
/// </summary>
public bool TryGetRecordOfThisChapter(out SongSelectionRecord record)
{
record = null;
ChapterSelectionUnit currentChapter = ChapterSelectionManager.instance.currentChapter;
if (songSelectionRecords.TryGetValue(currentChapter, out SongSelectionRecord record))
if (currentChapter == null || currentChapter.songs == null)
{
return record;
return false;
}
else
if (songSelectionRecords.TryGetValue(currentChapter, out SongSelectionRecord cachedRecord) && cachedRecord != null)
{
return new SongSelectionRecord(currentChapter.songs[0], currentChapter.songs[0].difficultyDataList[0]);
record = cachedRecord;
return true;
}
SongItemData firstValidSong = currentChapter.songs.Find(song => song != null);
if (firstValidSong == null)
{
return false;
}
record = new SongSelectionRecord(firstValidSong, 0);
songSelectionRecords[currentChapter] = record;
return true;
}
/// <summary>
/// 将缓存修正为当前 Chapter 中实际存在的歌曲,并保留调用方提供的难度列表偏好位置。
/// 这里只写内存缓存,玩家重启游戏后不会恢复该选择。
/// </summary>
public void SetRecordForChapter(ChapterSelectionUnit chapter, SongItemData song, int difficultyListIndex)
{
if (chapter == null || song == null)
{
return;
}
songSelectionRecords[chapter] = new SongSelectionRecord(song, difficultyListIndex);
}
}
public class SongSelectionRecord
{
public SongItemData song;
public int difficultyIndex;
public SongSelectionRecord(SongItemData song, DifficultyData difficulty)
public int difficultyListIndex;
/// <summary>
/// 使用难度在歌曲列表中的位置构建缓存。
/// 这是选曲 UI 的临时偏好,不等同于 DifficultyData.saveDifficultyId 的游戏记录稳定 ID。
/// </summary>
public SongSelectionRecord(SongItemData song, int difficultyListIndex)
{
this.song = song;
difficultyIndex = song.difficultyDataList.IndexOf(difficulty);
this.difficultyListIndex = difficultyListIndex;
}
public SongSelectionRecord(SongItemData song, DifficultyData difficulty)
: this(song, song?.difficultyDataList?.IndexOf(difficulty) ?? 0)
{
}
}
}
}

View File

@@ -3,10 +3,13 @@ using System.Collections;
using System.Collections.Generic;
using AK.Wwise;
using Ichni.Menu;
using Ichni.Menu.UI;
using Ichni.Story;
using Ichni.Story.UI;
using Ichni.UI;
using Sirenix.OdinInspector;
using SLSUtilities.WwiseAssistance;
using UniRx;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.Serialization;
@@ -21,6 +24,7 @@ namespace Ichni
public LoginPage loginPage;
public ChapterSelectionUIPage chapterSelectionUIPage;
public StoryUIPage storyUIPage;
public SelectionBoxUIPage selectionBoxUIPage;
// dialogUIPage 已随旧对话系统移除;阶段 3 将接入新的 VN 对话页面引用
public SongSelectionUIPage songSelectionUIPage;
public TransitionUIPage transitionUIPage;
@@ -34,6 +38,12 @@ namespace Ichni
{
AsyncOperation asyncOperation;
public bool isEnteringGame;
/// <summary>
/// 当前 MenuScene 是否已经完成 GameScene 预加载且尚未处于切场中。
/// TutorialFlowController 在写入“已处理”剧情变量前使用此状态进行最后一次启动前检查。
/// </summary>
public bool CanBeginGame => !isEnteringGame && asyncOperation != null;
private void Awake()
{
@@ -44,24 +54,39 @@ namespace Ichni
{
isEnteringGame = false;
if (InformationTransistor.instance.isReturnedFromGame)
InformationTransistor information = InformationTransistor.instance;
MenuReturnDestination returnDestination = information != null
? information.menuReturnDestination
: MenuReturnDestination.None;
if (returnDestination == MenuReturnDestination.SongSelection &&
information != null && information.chapter != null &&
information.song != null && information.difficulty != null)
{
startUIPage.mainCanvasGroup.gameObject.SetActive(false);
ChapterSelectionManager.instance.currentChapter =
InformationTransistor.instance.chapter;
information.chapter;
AudioManager.SetSwitch(ChapterSelectionManager.instance.currentChapter.chapterSwitch);
MenuInformationRecorder.instance.songSelectionRecords.Add(
ChapterSelectionManager.instance.currentChapter,
new SongSelectionRecord(InformationTransistor.instance.song, InformationTransistor.instance.difficulty));
MenuInformationRecorder.instance.songSelectionRecords[
ChapterSelectionManager.instance.currentChapter] =
new SongSelectionRecord(information.song, information.difficulty);
songSelectionUIPage.FadeIn();
}
else if (InformationTransistor.instance.isReturnedFromTutorial)
else if (returnDestination == MenuReturnDestination.Story &&
information != null && information.chapter != null)
{
startUIPage.mainCanvasGroup.gameObject.SetActive(false);
ChapterSelectionManager.instance.currentChapter = InformationTransistor.instance.chapter;
ChapterSelectionManager.instance.currentChapter = information.chapter;
AudioManager.SetSwitch(ChapterSelectionManager.instance.currentChapter.chapterSwitch);
// MenuScene 会在教程游玩后重新创建,必须显式重建章节故事树,
// 不能依赖旧场景中残留的 Block 实例。
StoryManager.instance?.OpenChapter(ChapterSelectionManager.instance.currentChapter.chapterIndex);
storyUIPage.FadeIn();
}
// 返回目标是一次性运行态信息。无论是否能恢复页面,都不能让它在下次重进菜单时重复生效。
information?.ClearMenuReturnDestination();
Application.targetFrameRate = SettingsManager.instance.gameSettings.targetFrame;
asyncOperation = SceneManager.LoadSceneAsync("GameScene");
@@ -73,15 +98,59 @@ namespace Ichni
{
public void EnterGame()
{
InformationTransistor.instance.isReturnedFromTutorial = false;
InformationTransistor.instance.isReturnedFromGame = true;
EnterGame(MenuReturnDestination.SongSelection);
}
/// <summary>
/// 激活已预加载的 GameScene并写入唯一的菜单返回目标。
/// 普通选曲使用 <see cref="MenuReturnDestination.SongSelection"/>,教程使用
/// <see cref="MenuReturnDestination.Story"/>。
/// </summary>
public void EnterGame(MenuReturnDestination returnDestination)
{
InformationTransistor.instance?.SetMenuReturnDestination(returnDestination);
EnterGameScene();
}
/// <summary>
/// 为 TutorialBlock 等非选曲入口准备上下文并播放通用转场。
/// 运行成功后会返回 true返回 false 时不会修改剧情变量或存档,调用方应保留原状。
/// </summary>
public bool TryBeginGame(ChapterSelectionUnit chapter, SongItemData song,
DifficultyData difficulty, MenuReturnDestination returnDestination, UIPageBase sourcePage)
{
if (!CanBeginGame)
{
Debug.LogWarning("[MenuManager] GameScene 未完成预加载或菜单已在切换,忽略启动请求。");
return false;
}
if (InformationTransistor.instance == null ||
!InformationTransistor.instance.PrepareGameLaunch(chapter, song, difficulty, returnDestination))
{
return false;
}
isEnteringGame = true;
sourcePage?.FadeOut();
transitionUIPage?.FadeIn();
AudioManager.Post(AK.EVENTS.ENTERTOGAME);
Observable.Timer(TimeSpan.FromSeconds(0.6f)).Subscribe(_ => EnterGameScene());
return true;
}
public void EnterGameScene()
{
MenuInputManager.instance.gameInput.Menu.Disable();
if (asyncOperation == null)
{
Debug.LogError("[MenuManager] GameScene 尚未完成预加载,无法进入游戏。");
isEnteringGame = false;
return;
}
MenuInputManager.instance?.gameInput?.Menu.Disable();
asyncOperation.allowSceneActivation = true;
}
}
}
}

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4029d987b6779e440b58bc5e9b457db1
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,144 @@
using System.Collections.Generic;
using Ichni.Menu.UI;
using Ichni.UI;
using UnityEngine;
namespace Ichni.Story.UI
{
/// <summary>
/// 用于在剧情系统中管理并顺序展示 MessageBox 的独立 UI 页面。
/// 支持动态实例化给定的 MessageBox Prefab并在所有消息展示完毕后进行大页面的 FadeOut。
/// </summary>
public class MessageUIPage : UIPageBase
{
public static MessageUIPage instance;
[Header("Prefabs & Containers")]
[Tooltip("默认使用的 MessageBox 预制体(请从 Project 中拖拽,而非场景中的实例)")]
public MessageBox defaultMessageBoxPrefab;
[Tooltip("生成的 MessageBox 挂载在哪?如果不填,默认挂载在自己身上")]
public Transform messageContainer;
// 用于排队的内部消息数据结构
private struct PendingMessage
{
public string title;
public string content;
public MessageBox customPrefab; // 支持未来传入不同种类的 Prefab
}
private Queue<PendingMessage> _messageQueue = new Queue<PendingMessage>();
private MessageBox _currentActiveBox;
private bool _isPageActive = false;
protected override void Awake()
{
base.Awake();
if (instance == null)
{
instance = this;
}
else
{
Debug.LogWarning("[StoryMessageBoxUIPage] 场景中存在多个实例,正在销毁多余的实例。");
Destroy(gameObject);
return;
}
if (messageContainer == null)
{
messageContainer = this.transform;
}
}
/// <summary>
/// 将歌曲解锁提示加入弹窗队列。
/// </summary>
public void ShowUnlockMessage(string songUnlockKey)
{
string title = "New Song Unlocked!";
string content = $"You have unlocked the song: {songUnlockKey}!";
EnqueueMessage(title, content, defaultMessageBoxPrefab);
}
/// <summary>
/// 将自定义文本提示加入弹窗队列。
/// </summary>
public void ShowCustomMessage(string title, string content)
{
EnqueueMessage(title, content, defaultMessageBoxPrefab);
}
/// <summary>
/// 将指定消息入队。如果是空闲状态,则唤醒大页面并开始处理队列。
/// </summary>
private void EnqueueMessage(string title, string content, MessageBox prefab)
{
if (prefab == null)
{
Debug.LogError("[StoryMessageBoxUIPage] 没有可用的 MessageBox 预制体!");
return;
}
_messageQueue.Enqueue(new PendingMessage
{
title = title,
content = content,
customPrefab = prefab
});
// 如果当前页面没有在显示中,则开始整体流程
if (!_isPageActive)
{
_isPageActive = true;
// 唤醒大页面(执行 UIPageBase 的渐入并拦截底层射线)
this.FadeIn(0.5f, false, ShowNextInQueue);
}
}
/// <summary>
/// 从队列中取出一个并实例化显示。
/// </summary>
private void ShowNextInQueue()
{
if (_messageQueue.Count == 0)
{
// 队列处理完毕,大页面整体退场
_currentActiveBox = null;
_isPageActive = false;
this.FadeOut();
return;
}
PendingMessage msg = _messageQueue.Dequeue();
// 实例化预制体
_currentActiveBox = Instantiate(msg.customPrefab, messageContainer);
// 订阅“当这个消息框彻底结束关闭时”的事件
_currentActiveBox.onAllMessagesClosed.AddListener(() =>
{
// 销毁旧实例
if (_currentActiveBox != null)
{
Destroy(_currentActiveBox.gameObject);
}
// 检查并显示下一个
ShowNextInQueue();
});
// 配置并启动 MessageBox
_currentActiveBox.Clear();
_currentActiveBox.AddInfo(msg.title, msg.content, null);
// MessageBox.SetUp() 内部会激活自己并执行自己的 FadeIn
_currentActiveBox.gameObject.SetActive(true);
_currentActiveBox.SetUp();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 07f2a7ffd4b513d4e90d60a6587914cc

View File

@@ -9,6 +9,35 @@ namespace Ichni.Menu
[CreateAssetMenu(fileName = "TutorialCollection", menuName = "Ichni/TutorialCollection")]
public class TutorialCollection : SerializedScriptableObject
{
/// <summary>
/// 教程 Key 到教程曲目配置的映射。
/// Key 必须使用小写英文、数字和下划线,例如 <c>chapter0_intro</c>
/// 不要使用章节展示名、曲名或点号作为 Key。
/// </summary>
public Dictionary<string, SongItemData> songs = new Dictionary<string, SongItemData>();
/// <summary>
/// 按 TutorialBlock 配置的稳定 Key 查找教程曲目。
/// 教程运行流程只通过此函数读取集合,避免各处直接访问字典而产生空引用或不一致的错误信息。
/// </summary>
public bool TryGetTutorialSong(string tutorialKey, out SongItemData song)
{
song = null;
if (string.IsNullOrWhiteSpace(tutorialKey))
{
Debug.LogWarning("[TutorialCollection] Tutorial Key 为空,无法查找教程曲目。");
return false;
}
if (songs == null || !songs.TryGetValue(tutorialKey, out song) || song == null)
{
Debug.LogWarning($"[TutorialCollection] 未找到 Tutorial Key '{tutorialKey}' 对应的教程曲目。");
song = null;
return false;
}
return true;
}
}
}
}