阶段性完成

This commit is contained in:
SoulliesOfficial
2026-07-25 13:27:53 -04:00
parent de70870682
commit a34461d31f
121 changed files with 7022 additions and 4111 deletions

View File

@@ -1,7 +1,7 @@
using System.Collections;
using System.Collections.Generic;
using I2.Loc;
using UnityEngine;
using Ichni.Localization;
using Ichni.UI;
using Michsky.MUIP;
using Sirenix.OdinInspector;
@@ -23,6 +23,10 @@ namespace Ichni.RhythmGame
public Image characterImage;
public TMP_Text characterNameText;
public TMP_Text sentenceText;
[Tooltip("角色名的 Unity Localization 文本。应配置 UI/ui_summary_soullies_name。")]
public LocalizedTMPText characterNameLocalizedText;
[Tooltip("角色短句的 Unity Localization 文本。应配置 UI/ui_summary_soullies_sentence_0。")]
public LocalizedTMPText sentenceLocalizedText;
[Title("Difficulty")]
public TMP_Text difficultyText;
@@ -61,8 +65,12 @@ namespace Ichni.RhythmGame
illustrationImage.sprite = info.song.illustration;
characterNameText.GetComponent<Localize>().SetTerm("Characters/Soullies");
sentenceText.GetComponent<Localize>().SetTerm("Sentences/Soullies_Summary_Sentence_0");
// Summary 页面没有再依赖 I2 Term。迁移期间允许旧场景先不绑定新字段
// 此时不会中断结算页面;完成 Inspector 配置后,两个 LocalizedTMPText 会自动跟随 Locale 切换。
characterNameLocalizedText ??= characterNameText.GetComponent<LocalizedTMPText>();
sentenceLocalizedText ??= sentenceText.GetComponent<LocalizedTMPText>();
characterNameLocalizedText?.RefreshText();
sentenceLocalizedText?.RefreshText();
difficultyText.text = info.difficulty.GetDifficultyName();
levelText.text = info.difficulty.difficultyValue.ToString();
@@ -89,4 +97,4 @@ namespace Ichni.RhythmGame
backButton.onClick.AddListener(GameManager.ReturnToMenu);
}
}
}
}

View File

@@ -0,0 +1,208 @@
using TMPro;
using UnityEngine;
using UnityEngine.Localization;
#if UNITY_EDITOR
using UnityEditor;
using UnityEditor.Localization;
using UnityEngine.Localization.Settings;
using UnityEngine.Localization.Tables;
#endif
namespace Ichni.Localization
{
/// <summary>
/// 将静态 <see cref="TMP_Text"/> 与 Unity Localization 的
/// <see cref="LocalizedString"/> 绑定。
/// <para>
/// 组件启用时订阅 <see cref="LocalizedString.StringChanged"/>。因此切换 Locale
/// 或在 Play Mode 中修改 Inspector 里的 String Reference 后TMP 文本会自动更新。
/// </para>
/// <para>
/// Inspector 自带的 String Reference 可预览各 Locale 的文本。Edit Mode 中变更引用时,
/// 本组件只将项目默认 Locale 的文本同步到 TMP_Text便于直接预览不保存回退文本
/// 也不提供额外的编辑器自检功能。
/// </para>
/// </summary>
[DisallowMultipleComponent]
[RequireComponent(typeof(TMP_Text))]
[AddComponentMenu("Localization/Localized TMP Text")]
public sealed class LocalizedTMPText : MonoBehaviour
{
[SerializeField]
[Tooltip("要更新的 TMP 文本。留空时会自动使用当前 GameObject 上的 TMP_Text。")]
private TMP_Text targetText;
[SerializeField]
[Tooltip("在 Inspector 中选择 String Table Collection 和对应的 Entry。")]
private LocalizedString stringReference = new LocalizedString();
private bool _isListening;
/// <summary>当前绑定的 TMP 文本。</summary>
public TMP_Text TargetText => targetText;
/// <summary>当前使用的 Unity Localization 字符串引用。</summary>
public LocalizedString StringReference => stringReference;
/// <summary>
/// 在运行时替换当前文本使用的 String Reference。
/// <para>
/// 该接口主要供动态生成的 UI 使用,例如 Dropdown 的选项 Item。替换前会先解除旧引用的
/// <see cref="LocalizedString.StringChanged"/> 订阅,再为新引用建立订阅,因此不会残留
/// 旧语言表的回调,也不会重复监听。
/// </para>
/// </summary>
public void SetStringReference(LocalizedString reference)
{
StopListening();
stringReference = reference ?? new LocalizedString();
EnsureTargetText();
if (isActiveAndEnabled)
{
StartListening();
}
#if UNITY_EDITOR
if (!Application.isPlaying)
{
ApplyEditorPreviewText();
}
#endif
}
private void Awake()
{
EnsureTargetText();
}
private void OnValidate()
{
EnsureTargetText();
if (Application.isPlaying)
{
// Play Mode 中通过 Inspector 更换 Table 或 Entry 后立即刷新。
if (_isListening)
{
stringReference?.RefreshString();
}
return;
}
#if UNITY_EDITOR
ApplyEditorPreviewText();
#endif
}
private void OnEnable()
{
EnsureTargetText();
StartListening();
}
private void OnDisable()
{
StopListening();
}
private void OnDestroy()
{
StopListening();
}
/// <summary>
/// 主动请求重新解析当前 String Reference。
/// 通常仅在运行时修改 Smart String 参数后调用;普通语言切换会自动刷新。
/// </summary>
public void RefreshText()
{
if (_isListening)
{
stringReference.RefreshString();
}
}
private void StopListening()
{
if (!_isListening || stringReference == null)
{
return;
}
stringReference.StringChanged -= ApplyText;
_isListening = false;
}
/// <summary>
/// 为当前 String Reference 建立语言变化监听。
/// Unity Localization 会在首次订阅时请求当前 Locale 的文本;这里不使用
/// <c>WaitForCompletion</c>,以避免阻塞主线程或干扰 Addressables 队列。
/// </summary>
private void StartListening()
{
if (_isListening || stringReference == null || stringReference.IsEmpty)
{
return;
}
stringReference.StringChanged += ApplyText;
_isListening = true;
}
private void EnsureTargetText()
{
if (targetText == null)
{
targetText = GetComponent<TMP_Text>();
}
}
private void ApplyText(string localizedText)
{
EnsureTargetText();
if (targetText != null)
{
targetText.text = localizedText ?? string.Empty;
}
}
#if UNITY_EDITOR
/// <summary>
/// 在编辑器中将项目默认 Locale 的已导入文本同步到 TMP_Text。
/// 这里直接读取 String Table 资产,不调用异步 Localization API因而不会加载 Addressables。
/// </summary>
private void ApplyEditorPreviewText()
{
if (targetText == null || stringReference == null || stringReference.IsEmpty)
{
return;
}
Locale projectLocale = LocalizationSettings.ProjectLocale;
StringTableCollection tableCollection =
LocalizationEditorSettings.GetStringTableCollection(stringReference.TableReference);
StringTable table = projectLocale == null
? null
: tableCollection?.GetTable(projectLocale.Identifier) as StringTable;
StringTableEntry entry = table?.GetEntryFromReference(stringReference.TableEntryReference);
if (entry == null)
{
return;
}
string previewText = entry.LocalizedValue ?? string.Empty;
if (targetText.text == previewText)
{
return;
}
targetText.text = previewText;
EditorUtility.SetDirty(targetText);
}
#endif
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 209017a926f587a4394f52dc1e88a61e

View File

@@ -1,11 +1,56 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Ichni.Localization;
using Ichni.Menu.UI;
using Ichni.UI;
using Sirenix.OdinInspector;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.Localization;
using UnityEngine.Localization.Tables;
namespace Ichni.Story.UI
{
/// <summary>
/// 运行时弹窗的一段本地化文本请求。
/// <para>请求进入队列时不会立即翻译;<see cref="MessageUIPage"/> 即将显示该弹窗时才读取当前 Locale
/// 因而不会因异步加载导致 FIFO 顺序错乱,也不会把语言逻辑散落到各业务调用方。</para>
/// <para>动态参数统一使用 Smart String 的位置参数,例如 <c>{0}</c>、<c>{1}</c>。</para>
/// </summary>
public readonly struct LocalizedPopupText
{
public readonly TableReference tableReference;
public readonly string entryKey;
public readonly object[] arguments;
public LocalizedPopupText(TableReference tableReference, string entryKey, params object[] arguments)
{
this.tableReference = tableReference;
this.entryKey = entryKey;
this.arguments = arguments;
}
/// <summary>异步解析当前 Locale 的文本;缺失时显示 Key便于开发阶段定位错误配置。</summary>
public Task<string> ResolveAsync() =>
LocalizationTextService.ResolveAsync(tableReference, entryKey, entryKey, arguments);
}
/// <summary>
/// SelectionBox 的本地化按钮定义。每个按钮可以独立指定 String Table
/// 因此业务专用操作与 UI 通用操作(如“取消”)可复用各自的词条。
/// </summary>
public readonly struct LocalizedSelectionBoxOption
{
public readonly LocalizedPopupText label;
public readonly UnityAction action;
public LocalizedSelectionBoxOption(LocalizedPopupText label, UnityAction action)
{
this.label = label;
this.action = action;
}
}
/// <summary>
/// 剧情与菜单共用的弹窗页面。
/// MessageBox 与 SelectionBox 共用同一遮罩、容器和显示队列:两者不会重叠,
@@ -39,6 +84,10 @@ namespace Ichni.Story.UI
public string content;
public MessageBox messagePrefab;
public List<SelectionBoxOption> selectionOptions;
public bool useLocalizedText;
public LocalizedPopupText localizedTitle;
public LocalizedPopupText localizedContent;
public List<LocalizedSelectionBoxOption> localizedSelectionOptions;
}
private readonly Queue<PendingPopup> _popupQueue = new();
@@ -69,12 +118,13 @@ namespace Ichni.Story.UI
/// <summary>
/// 将歌曲解锁提示加入弹窗队列。
/// <param name="songDisplayName">玩家可见的歌曲名称;调用方不得传入内部 Unlock Key。</param>
/// </summary>
public void ShowUnlockMessage(string songUnlockKey)
public void ShowUnlockMessage(string songDisplayName)
{
string title = "New Song Unlocked!";
string content = $"You have unlocked the song: {songUnlockKey}!";
EnqueueMessage(title, content, defaultMessageBoxPrefab);
ShowLocalizedMessage(
new LocalizedPopupText("Message", "system_unlock_song_title"),
new LocalizedPopupText("Message", "system_unlock_song_content", songDisplayName));
}
/// <summary>
@@ -85,6 +135,32 @@ namespace Ichni.Story.UI
EnqueueMessage(title, content, defaultMessageBoxPrefab);
}
/// <summary>
/// 将本地化 MessageBox 加入队列。文本会在该弹窗真正显示前才按当前 Locale 解析;
/// 对于含动态参数的词条,请使用 <see cref="LocalizedPopupText"/> 的位置参数构造函数。
/// </summary>
public void ShowLocalizedMessage(LocalizedPopupText title, LocalizedPopupText content,
MessageBox prefab = null)
{
MessageBox targetPrefab = prefab ?? defaultMessageBoxPrefab;
if (targetPrefab == null)
{
Debug.LogError("[MessageUIPage] 没有可用的 MessageBox Prefab无法显示本地化消息。");
return;
}
_popupQueue.Enqueue(new PendingPopup
{
type = PopupType.Message,
messagePrefab = targetPrefab,
useLocalizedText = true,
localizedTitle = title,
localizedContent = content
});
BeginQueueIfNeeded();
}
/// <summary>
/// Inspector 专用的运行时调试入口。
/// 通过正常队列创建一条基础测试文本,因此可以同时验证新 Prefab 的布局、
@@ -162,6 +238,40 @@ namespace Ichni.Story.UI
return true;
}
/// <summary>
/// 将本地化 SelectionBox 加入队列。每个选项的标签单独保存本地化引用,
/// 因此“进入教程”“跳过教程”等业务按钮不会被误替换为通用的“确定/取消”。
/// </summary>
public bool ShowLocalizedSelectionBox(
LocalizedPopupText title,
LocalizedPopupText content,
IReadOnlyList<LocalizedSelectionBoxOption> options)
{
if (defaultSelectionBoxPrefab == null)
{
Debug.LogError("[MessageUIPage] 未配置 defaultSelectionBoxPrefab无法显示本地化选项框。");
return false;
}
if (options == null || options.Count == 0)
{
Debug.LogWarning("[MessageUIPage] 未提供任何本地化 SelectionBoxOption忽略空选项框。");
return false;
}
_popupQueue.Enqueue(new PendingPopup
{
type = PopupType.Selection,
useLocalizedText = true,
localizedTitle = title,
localizedContent = content,
localizedSelectionOptions = new List<LocalizedSelectionBoxOption>(options)
});
BeginQueueIfNeeded();
return true;
}
/// <summary>
/// 将指定消息入队。如果是空闲状态,则唤醒大页面并开始处理队列。
/// </summary>
@@ -202,7 +312,7 @@ namespace Ichni.Story.UI
/// <summary>
/// 从队列中取出一个并实例化显示。
/// </summary>
private void ShowNextInQueue()
private async void ShowNextInQueue()
{
if (_popupQueue.Count == 0)
{
@@ -215,6 +325,11 @@ namespace Ichni.Story.UI
}
PendingPopup popup = _popupQueue.Dequeue();
if (popup.useLocalizedText)
{
popup = await ResolveLocalizedPopupAsync(popup);
}
if (popup.type == PopupType.Selection)
{
ShowSelectionPopup(popup);
@@ -224,6 +339,30 @@ namespace Ichni.Story.UI
ShowMessagePopup(popup);
}
/// <summary>
/// 按 Popup 自己的请求依次解析标题、正文与按钮。该方法只由队列消费端调用,
/// 所以即使 String Table 仍在异步加载,也不会打乱此前入队的弹窗顺序。
/// </summary>
private static async Task<PendingPopup> ResolveLocalizedPopupAsync(PendingPopup popup)
{
popup.title = await popup.localizedTitle.ResolveAsync();
popup.content = await popup.localizedContent.ResolveAsync();
if (popup.type != PopupType.Selection || popup.localizedSelectionOptions == null)
{
return popup;
}
popup.selectionOptions = new List<SelectionBoxOption>(popup.localizedSelectionOptions.Count);
foreach (LocalizedSelectionBoxOption localizedOption in popup.localizedSelectionOptions)
{
string label = await localizedOption.label.ResolveAsync();
popup.selectionOptions.Add(new SelectionBoxOption(label, localizedOption.action));
}
return popup;
}
private void ShowMessagePopup(PendingPopup popup)
{
if (popup.messagePrefab == null)

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using Sirenix.OdinInspector;
using UnityEngine;
using UnityEngine.Localization;
namespace Ichni.Story
{
@@ -147,9 +148,9 @@ namespace Ichni.Story
[FoldoutGroup("$EditorHeader/Text Content")]
[ShowIf("blockType", StoryBlockType.Text)]
[LabelText("Title Key")]
[Tooltip("该 TextBlock 标题使用的 Unity Localization Entry Key留空时视觉层使用 Block ID 作为开发阶段回退。")]
public string titleKey;
[LabelText("Title")]
[Tooltip("该 TextBlock 的 Unity Localization String Reference。请在此直接选择 String Table 与 Entry留空时视觉层使用 Block ID 作为开发阶段回退。")]
public LocalizedString title = new LocalizedString();
/// <summary>
/// TextBlock 的视觉和叙事层级。Important 用于主线转折与后续 checkpoint
@@ -229,7 +230,9 @@ namespace Ichni.Story
{
string title = blockType switch
{
StoryBlockType.Text => titleKey,
StoryBlockType.Text => this.title != null && !this.title.IsEmpty
? this.title.TableEntryReference.ToString()
: null,
StoryBlockType.Song => songName,
StoryBlockType.Tutorial => tutorialName,
_ => null

View File

@@ -3,7 +3,9 @@ using System.Collections.Generic;
using System.Linq;
using Sirenix.OdinInspector;
using UnityEditor;
using UnityEditor.Localization;
using UnityEngine;
using UnityEngine.Localization;
namespace Ichni.Story
{
@@ -146,10 +148,7 @@ namespace Ichni.Story
issues.Add($"[Error] Timeline Marker '{marker.markerId}' 的 Anchor Block '{marker.anchorBlockId}' 不是 TextBlock。");
}
if (string.IsNullOrWhiteSpace(marker.labelKey))
{
issues.Add($"[Warning] Timeline Marker '{marker.markerId}' 未配置 Label Key。");
}
ValidateLocalizedString(marker.label, $"Timeline Marker '{marker.markerId}' Label", issues);
}
ValidateTimelineMarkerAnchors(reachableBlockIds, issues);
@@ -203,8 +202,10 @@ namespace Ichni.Story
switch (block.blockType)
{
case StoryBlockType.Text when string.IsNullOrWhiteSpace(block.yarnNodeName):
issues.Add($"[Warning] TextBlock '{block.blockId}' 未配置 Yarn Node。");
case StoryBlockType.Text:
if (string.IsNullOrWhiteSpace(block.yarnNodeName))
issues.Add($"[Warning] TextBlock '{block.blockId}' 未配置 Yarn Node。");
ValidateLocalizedString(block.title, $"TextBlock '{block.blockId}' Title", issues);
break;
case StoryBlockType.Song when string.IsNullOrWhiteSpace(block.songName):
issues.Add($"[Warning] SongBlock '{block.blockId}' 未配置 Song ID。");
@@ -295,6 +296,36 @@ namespace Ichni.Story
}
}
/// <summary>
/// 检查 LocalizedString 是否已选择有效的 String Table 与 Entry。
/// 只读取 Unity Localization 的 Shared Table Data不加载 Addressables也不修改资产
/// 因此可在 StoryData Inspector 中尽早发现 CSV 尚未导入或引用错误。
/// </summary>
private static void ValidateLocalizedString(
LocalizedString reference,
string owner,
List<string> issues)
{
if (reference == null || reference.IsEmpty)
{
issues.Add($"[Warning] {owner} 未配置 LocalizedString。");
return;
}
StringTableCollection tableCollection =
LocalizationEditorSettings.GetStringTableCollection(reference.TableReference);
if (tableCollection?.SharedData == null)
{
issues.Add($"[Error] {owner} 引用的 String Table '{reference.TableReference}' 不存在或无法读取 Shared Table Data。");
return;
}
if (tableCollection.SharedData.GetEntryFromReference(reference.TableEntryReference) == null)
{
issues.Add($"[Error] {owner} 引用的 Entry '{reference.TableEntryReference}' 不存在于 String Table '{tableCollection.TableCollectionName}'。");
}
}
private HashSet<string> ValidateStoryGraph(HashSet<string> blockIds, List<string> issues)
{
Dictionary<string, List<string>> adjacency = new Dictionary<string, List<string>>();

View File

@@ -1,6 +1,7 @@
using System;
using Sirenix.OdinInspector;
using UnityEngine;
using UnityEngine.Localization;
namespace Ichni.Story
{
@@ -8,8 +9,8 @@ namespace Ichni.Story
/// 一个章节 Timeline 标记的静态定义。
/// <para>它不保存独立的横坐标:运行时始终跟随锚定 Block 的 <c>gridColumn</c>
/// 使时间轴与剧情树在横向拖动时保持对齐。</para>
/// <para>显示文本和 ID 使用 Unity Localization 的 Entry Key本阶段不绑定具体 String Table
/// 以便后续统一迁移本地化系统时集中配置。</para>
/// <para>显示文本直接使用 <see cref="LocalizedString"/>,因此可在 Inspector 中选择
/// String Table 与 Entry并预览各语言的值。</para>
/// </summary>
[Serializable]
[InlineProperty]
@@ -37,13 +38,13 @@ namespace Ichni.Story
public string anchorBlockId;
/// <summary>
/// Timeline 上显示的本地化文本 Key。通常用于时间描述,也可用于特殊叙事文本。
/// Timeline 上显示的本地化文本。通常用于时间描述,也可用于特殊叙事文本。
/// </summary>
[HorizontalGroup("Identity")]
[LabelText("Label Key")]
[LabelWidth(54)]
[Tooltip("Timeline 上显示的 Unity Localization Entry Key通常用于时间或特殊叙事文本。")]
public string labelKey;
[LabelText("Label")]
[LabelWidth(40)]
[Tooltip("Timeline 上显示的 Unity Localization String Reference。请在此直接选择对应 String Table 与 Entry。")]
public LocalizedString label = new LocalizedString();
/// <summary>
/// 是否参与章节主线进度百分比计算。支线或纯装饰性时间标记应关闭此项。

View File

@@ -3,6 +3,8 @@ using Ichni.Menu.UI;
using Ichni.Story.UI;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Localization;
using UnityEngine.Localization.Tables;
namespace Ichni.Story
{
@@ -13,6 +15,9 @@ namespace Ichni.Story
/// </summary>
public static class TutorialFlowController
{
/// <summary>教程确认弹窗统一使用 Message String Table避免业务代码内嵌可见文案。</summary>
private static readonly TableReference MessageTable = "Message";
/// <summary>
/// 由 <see cref="UI.TutorialBlockView"/> 点击时调用。配置无效或 MessageUIPage 未配置时只报错,不修改剧情进度。
/// </summary>
@@ -32,14 +37,18 @@ namespace Ichni.Story
}
// TutorialFlowController 只描述“有哪些选择、各自触发什么效果”;
// 具体按钮由通用 SelectionBox 在运行时生成,后续其它系统可复用同一 UI
messageUIPage.ShowSelectionBox(
tutorialBlock.tutorialName,
string.Empty,
new List<SelectionBoxOption>
// 具体按钮由通用 SelectionBox 在运行时生成,且按钮文案与标题均由 Message 表解析
messageUIPage.ShowLocalizedSelectionBox(
new LocalizedPopupText(MessageTable, "system_tutorial_entry_title"),
new LocalizedPopupText(MessageTable, "system_tutorial_entry_content"),
new List<LocalizedSelectionBoxOption>
{
new("游玩教程", () => PlayTutorial(tutorialBlock, chapter, song, difficulty)),
new("跳过教程", () => SkipTutorial(tutorialBlock))
new(
new LocalizedPopupText(MessageTable, "system_tutorial_enter"),
() => PlayTutorial(tutorialBlock, chapter, song, difficulty)),
new(
new LocalizedPopupText(MessageTable, "system_tutorial_skip"),
() => SkipTutorial(tutorialBlock))
});
}

View File

@@ -1,12 +1,9 @@
using System;
using System.Collections.Generic;
using Ichni.Localization;
using Ichni.Menu.UI;
using TMPro;
using UnityEngine;
using UnityEngine.Localization;
using UnityEngine.Localization.Settings;
using UnityEngine.Localization.Tables;
using UnityEngine.UI;
namespace Ichni.Story.UI
@@ -39,15 +36,13 @@ namespace Ichni.Story.UI
[Tooltip("自动在左右各扩展一个刻度周期,保证刻度条循环平移时不会从 Timeline 边缘露出空白。")]
public bool expandTickRepeatRect = true;
[Header("Localization")]
[Tooltip("StoryTimeline 的 Unity Localization String Table。Marker.labelKey 在该 Table 中查找。")]
public TableReference localizationTable;
private sealed class RuntimeMarker
{
public StoryTimelineMarkerDefinition definition;
public StoryBlockView anchorView;
public StoryTimelineMarkerView view;
public string localizedLabel;
public LocalizedString.ChangeHandler localizedLabelChanged;
}
private readonly List<RuntimeMarker> _markers = new List<RuntimeMarker>();
@@ -69,7 +64,6 @@ namespace Ichni.Story.UI
private void OnEnable()
{
LocalizationSettings.SelectedLocaleChanged += OnSelectedLocaleChanged;
SubscribeToStoryScrollRect();
// StoryPage 从对话界面返回后Canvas 与 StoryTree 会在同一帧重新启用。
@@ -79,7 +73,6 @@ namespace Ichni.Story.UI
private void OnDisable()
{
LocalizationSettings.SelectedLocaleChanged -= OnSelectedLocaleChanged;
UnsubscribeFromStoryScrollRect();
}
@@ -124,6 +117,7 @@ namespace Ichni.Story.UI
foreach (RuntimeMarker marker in _markers)
{
UnbindLocalizedLabel(marker);
if (marker.view != null)
{
Destroy(marker.view.gameObject);
@@ -356,16 +350,19 @@ namespace Ichni.Story.UI
StoryTimelineMarkerView view = Instantiate(markerPrefab, markerContainer);
view.name = $"StoryTimelineMarker_{marker.markerId}";
view.SetLabel(GetLocalizedLabel(marker.labelKey));
RuntimeMarker runtimeMarker = new RuntimeMarker
{
definition = marker,
anchorView = anchorView,
view = view
view = view,
localizedLabel = marker.markerId
};
view.BindClick(() => RequestRollback(runtimeMarker));
_markers.Add(runtimeMarker);
// LocalizedString 首次读取是异步的;先显示稳定 Marker ID避免 Prefab 默认文字短暂闪现。
view.SetLabel(runtimeMarker.localizedLabel);
BindLocalizedLabel(runtimeMarker);
return true;
}
@@ -388,48 +385,52 @@ namespace Ichni.Story.UI
return;
}
string markerLabel = GetLocalizedLabel(marker.definition.labelKey);
messageUIPage.ShowSelectionBox(
"重开本章节",
$"将重置“{markerLabel}”所在列及其之后的剧情选择与进度。歌曲成绩、解锁内容、设置和其它章节不会受到影响。",
new List<SelectionBoxOption>
string markerLabel = marker.localizedLabel;
messageUIPage.ShowLocalizedSelectionBox(
new LocalizedPopupText("Message", "system_rollback_chapter_title"),
new LocalizedPopupText("Message", "system_rollback_chapter_content", markerLabel),
new List<LocalizedSelectionBoxOption>
{
new SelectionBoxOption("重开此时间点", () => _treeController.TryRollbackToMarker(marker.definition.markerId)),
new SelectionBoxOption("取消", () => { })
new LocalizedSelectionBoxOption(
new LocalizedPopupText("Message", "system_rollback_confirm"),
() => _treeController.TryRollbackToMarker(marker.definition.markerId)),
new LocalizedSelectionBoxOption(
new LocalizedPopupText("UI", "ui_common_cancel"),
() => { })
});
}
/// <summary>
/// 使用 StoryTimeline 指定的 Unity Localization String Table 解析 Label Key。
/// String Table 或条目尚未配置时回退显示 Key确保开发阶段仍能从 UI 上定位缺失翻译
/// 订阅 Marker 自身的 LocalizedString。LocalizedString 会负责首次异步读取与 Locale
/// 切换后的自动刷新Timeline 不再维护独立的 Table / Key 解析逻辑
/// </summary>
private string GetLocalizedLabel(string labelKey)
private static void BindLocalizedLabel(RuntimeMarker marker)
{
if (string.IsNullOrEmpty(labelKey))
if (marker?.definition?.label == null || marker.definition.label.IsEmpty)
{
return string.Empty;
marker?.view?.SetLabel(marker?.localizedLabel ?? string.Empty);
return;
}
return LocalizationTextService.Resolve(localizationTable, labelKey, labelKey);
marker.localizedLabelChanged = localizedText =>
{
marker.localizedLabel = string.IsNullOrEmpty(localizedText)
? marker.definition.markerId
: localizedText;
if (marker.view != null)
marker.view.SetLabel(marker.localizedLabel);
};
marker.definition.label.StringChanged += marker.localizedLabelChanged;
}
private async void OnSelectedLocaleChanged(Locale _)
private static void UnbindLocalizedLabel(RuntimeMarker marker)
{
foreach (RuntimeMarker marker in _markers)
{
if (string.IsNullOrEmpty(marker.definition.labelKey)) continue;
if (marker?.definition?.label == null || marker.localizedLabelChanged == null)
return;
string localizedLabel = await LocalizationTextService.ResolveAsync(
localizationTable,
marker.definition.labelKey,
marker.definition.labelKey);
// 异步等待结束后,防范对象在此期间被销毁。
if (marker.view != null)
{
marker.view.SetLabel(localizedLabel);
}
}
marker.definition.label.StringChanged -= marker.localizedLabelChanged;
marker.localizedLabelChanged = null;
}
/// <summary>

View File

@@ -2,6 +2,7 @@ using System.Collections.Generic;
using Ichni.Story.Dialogue;
using TMPro;
using UnityEngine;
using UnityEngine.Localization;
using UnityEngine.UI;
namespace Ichni.Story.UI
@@ -24,6 +25,21 @@ namespace Ichni.Story.UI
// 当前已实例化的状态背景对象;状态切换时销毁并替换
private GameObject _currentVisual;
// 直接持有 StoryData 的 LocalizedString并订阅其 StringChanged 回调。
// Unity Localization 会在首次订阅和语言切换时自行异步刷新文本。
private LocalizedString _titleReference;
private bool _isTitleLocalizationSubscribed;
private void OnEnable()
{
StartTitleLocalization();
}
private void OnDisable()
{
StopTitleLocalization();
}
public override void Initialize(StoryBlockDefinition def, Vector2 position, StoryBlockState blockState)
{
// 注意base.Initialize 末尾会调用 ApplyState(state),届时已生成对应背景
@@ -34,9 +50,43 @@ namespace Ichni.Story.UI
if (storyIdText != null)
storyIdText.text = def.blockId;
// 当前仍直接显示 titleKey正式内容接入时需要由 Unity Localization 解析玩家可见标题。
StopTitleLocalization();
_titleReference = def.title;
StartTitleLocalization();
}
/// <summary>
/// 订阅当前 TextBlock 的 LocalizedString。无需手写 Table / Key 读取或监听全局 Locale
/// LocalizedString 会遵循 Unity 官方生命周期异步加载,并在语言变化后触发 StringChanged。
/// </summary>
private void StartTitleLocalization()
{
if (_isTitleLocalizationSubscribed || titleText == null)
return;
if (_titleReference == null || _titleReference.IsEmpty)
{
titleText.text = blockId;
return;
}
_titleReference.StringChanged += ApplyLocalizedTitle;
_isTitleLocalizationSubscribed = true;
}
private void StopTitleLocalization()
{
if (!_isTitleLocalizationSubscribed || _titleReference == null)
return;
_titleReference.StringChanged -= ApplyLocalizedTitle;
_isTitleLocalizationSubscribed = false;
}
private void ApplyLocalizedTitle(string localizedTitle)
{
if (titleText != null)
titleText.text = def.titleKey;
titleText.text = string.IsNullOrEmpty(localizedTitle) ? blockId : localizedTitle;
}
/// <summary>

View File

@@ -1,6 +1,7 @@
using Ichni.Story.Dialogue;
using Ichni.Story.UI;
using Ichni.Localization;
using Ichni.Menu;
using System.Collections.Generic;
using UnityEngine;
using Yarn.Unity;
@@ -14,14 +15,17 @@ namespace Ichni.Story.YarnFunctions
// ── Yarn Commands ────────────────────────────────────────────────────────
/// <summary>
/// 兼容既有 Yarn 脚本:授予歌曲相关的解锁 Key并在当前对话结束后弹出提示。
/// 格式: &lt;&lt;unlock_song unlock_key&gt;&gt;
/// 授予歌曲相关的解锁 Key并在当前对话结束后弹出提示。
/// 格式: &lt;&lt;unlock_song unlock_key "song_id"&gt;&gt;
/// <para>Key 必须使用小写字母、数字和下划线,例如 <c>story_ch0_prologue_completed</c>。</para>
/// <para><c>song_id</c> 必须是 <see cref="SongItemData.songName"/> 的稳定标识,而不是显示名称或本地化文本。
/// 该参数用于解析玩家可见的歌曲名称,绝不能将技术 Unlock Key 显示给玩家。</para>
/// 新内容若不需要歌曲提示,优先使用 <see cref="GrantUnlock"/>。
/// </summary>
/// <param name="songUnlockKey">要授予的稳定解锁 Key而不是歌曲显示名称。</param>
/// <param name="songId">对应 <see cref="SongItemData.songName"/> 的稳定歌曲 ID。</param>
[YarnCommand("unlock_song")]
public static void UnlockSong(string songUnlockKey)
public static void UnlockSong(string songUnlockKey, string songId)
{
ExecuteAfterDialogueCommit(() =>
{
@@ -31,7 +35,7 @@ namespace Ichni.Story.YarnFunctions
}
if (MessageUIPage.instance != null)
MessageUIPage.instance.ShowUnlockMessage(songUnlockKey);
MessageUIPage.instance.ShowUnlockMessage(ResolveSongDisplayName(songId));
else
Debug.LogError("[StoryTreeCommands] 弹窗失败:场景中未找到 StoryMessageBoxUIPage 实例!");
@@ -57,7 +61,7 @@ namespace Ichni.Story.YarnFunctions
/// <summary>
/// 在对话结束后,弹出一个自定义 MessageBox 提示。
/// 会尝试使用 Unity Localization 进行翻译如果找不到对应条目则回退显示原文本Key
/// 文本 Key 会交给 MessageUIPage 的 FIFO 队列,并在该弹窗真正显示前才按当前 Locale 解析
/// 格式: &lt;&lt;show_message titleKey contentKey [tableName]&gt;&gt;
/// </summary>
/// <param name="titleKey">标题文本或本地化键</param>
@@ -66,13 +70,13 @@ namespace Ichni.Story.YarnFunctions
[YarnCommand("show_message")]
public static void ShowMessage(string titleKey, string contentKey, string tableName = "Message")
{
string translatedTitle = GetTranslatedText(tableName, titleKey);
string translatedContent = GetTranslatedText(tableName, contentKey);
Debug.Log($"[StoryTreeCommands] 准备在对话结束后显示自定义弹窗:标题='{translatedTitle}',内容='{translatedContent}'");
Debug.Log($"[StoryTreeCommands] 准备在对话结束后显示本地化弹窗Table='{tableName}'TitleKey='{titleKey}'ContentKey='{contentKey}'。");
ExecuteAfterDialogueCommit(() =>
{
if (MessageUIPage.instance != null)
MessageUIPage.instance.ShowCustomMessage(translatedTitle, translatedContent);
MessageUIPage.instance.ShowLocalizedMessage(
new LocalizedPopupText(tableName, titleKey),
new LocalizedPopupText(tableName, contentKey));
else
Debug.LogError("[StoryTreeCommands] 无法显示自定义弹窗:未找到 MessageUIPage 实例。");
});
@@ -128,9 +132,36 @@ namespace Ichni.Story.YarnFunctions
// ── 内部辅助 ─────────────────────────────────────────────────────────────
private static string GetTranslatedText(string tableName, string key)
/// <summary>
/// 以稳定 Song ID 查找当前项目中配置的歌曲显示名。当前阶段使用 <see cref="SongItemData.displaySongName"/>
/// 将来歌曲标题接入 Chapter Content 本地化后只需替换本函数内部的返回逻辑Yarn 与弹窗 API 无需修改。
/// </summary>
private static string ResolveSongDisplayName(string songId)
{
return LocalizationTextService.Resolve(tableName, key, key);
List<ChapterSelectionUnit> chapters = ChapterSelectionManager.instance?.chapters;
if (chapters != null)
{
foreach (ChapterSelectionUnit chapter in chapters)
{
if (chapter?.songs == null)
{
continue;
}
foreach (SongItemData song in chapter.songs)
{
if (song != null && song.songName == songId)
{
return string.IsNullOrWhiteSpace(song.displaySongName)
? song.songName
: song.displaySongName;
}
}
}
}
Debug.LogWarning($"[StoryTreeCommands] 无法为歌曲解锁提示解析 Song ID '{songId}',将显示该 ID。", ChapterSelectionManager.instance);
return songId ?? string.Empty;
}
}
}

View File

@@ -1,18 +1,21 @@
//using I2.Loc;
using UnityEngine;
namespace SLSUtilities.General
{
/// <summary>
/// 历史兼容扩展。
/// <para>
/// 项目不再通过字符串扩展读取 I2 Localization运行时动态本地化请使用
/// <c>Ichni.Localization.LocalizationTextService</c>,静态 TMP 文本请使用
/// <c>Ichni.Localization.LocalizedTMPText</c>。
/// </para>
/// </summary>
public static class StringExtension
{
/// <summary>
/// 保持旧调用点的二进制/源码兼容。当前不会执行任何本地化解析。
/// </summary>
public static string Localize(this string original)
{
/*if (LocalizationManager.TryGetTranslation(original, out string translated))
{
return translated;
}*/
return original;
}
}
}
}

View File

@@ -1,2 +1,2 @@
fileFormatVersion: 2
guid: fd4a697d946242343a065437c29f0cf2
guid: fd4a697d946242343a065437c29f0cf2

View File

@@ -1,48 +1,298 @@
using System.Collections;
using System.Collections.Generic;
using Ichni.Localization;
using Michsky.MUIP;
using TMPro;
using UnityEngine;
using UnityEngine.Localization;
namespace Ichni.UI
{
/// <summary>
/// Settings 页面使用的下拉控件。
/// <para>
/// 本地化枚举项由 <see cref="SetUpLocalized(int,string,string[])"/> 在代码中传入 String Table 名称与
/// Entry Key。控件会创建对应的 <see cref="LocalizedString"/>,并在 Michsky 生成 Item 后为每个
/// 可见文字绑定 <see cref="LocalizedTMPText"/>;切换语言时不重建列表。
/// </para>
/// <para>
/// 分辨率、语言自称等运行时文本则使用 <see cref="SetUpRuntime"/>,不会附加本地化组件。
/// </para>
/// </summary>
public class Dropdown : SettingsUIElementBase
{
public List<string> options;
// 仅保存本次初始化创建的运行时引用,不在 Inspector 中维护本地化选项列表。
private readonly List<LocalizedString> _localizedOptions = new List<LocalizedString>();
// 保留原字段以兼容已有的外部读取。静态本地化选项使用稳定的占位名称;实际显示内容由
// 动态生成 Item 上的 LocalizedTMPText 管理。
[HideInInspector] public List<string> options = new List<string>();
public CustomDropdown dropdown;
public int selectedIndex;
public string selectedOption;
public void SetUp(int initialValue, List<string> options, string title = "", bool preservePrefabTitle = false)
private bool _usesLocalizedOptions;
private bool _isValueChangeListenerRegistered;
private Coroutine _bindGeneratedItemsCoroutine;
/// <summary>
/// 使用代码提供的 String Table 与 Entry Key 初始化静态枚举选项。
/// <paramref name="entryKeys"/> 的顺序即设置枚举的索引顺序,必须保持稳定。
/// 标题和说明仍由其 TMP 对象上的 <see cref="LocalizedTMPText"/> 独立管理。
/// </summary>
public void SetUpLocalized(int initialValue, string tableCollection, params string[] entryKeys)
{
base.SetUp(title, preservePrefabTitle: preservePrefabTitle);
this.options = options;
this.dropdown.items = new List<CustomDropdown.Item>();
foreach (string option in options)
base.SetUp();
ClearLocalizedBindings();
ClearLocalizedOptionDefinitions();
_usesLocalizedOptions = true;
foreach (string entryKey in entryKeys)
{
dropdown.items.Add(new CustomDropdown.Item { itemName = option });
_localizedOptions.Add(new LocalizedString(tableCollection, entryKey));
}
dropdown.onValueChanged.AddListener(value =>
{
SetValue(value);
updateValueAction?.Invoke();
});
BuildLocalizedOptionPlaceholders();
EnsureValueChangeListener();
SetValue(initialValue);
ApplyOptionsToDropdown();
}
/// <summary>
/// 使用运行时生成的纯文本选项初始化下拉框。
/// 仅用于分辨率、语言自称等不应随当前 Locale 再次翻译的内容。
/// </summary>
public void SetUpRuntime(int initialValue, IReadOnlyList<string> runtimeOptions)
{
base.SetUp();
ClearLocalizedBindings();
ClearLocalizedOptionDefinitions();
_usesLocalizedOptions = false;
options = runtimeOptions == null ? new List<string>() : new List<string>(runtimeOptions);
EnsureValueChangeListener();
SetValue(initialValue);
ApplyOptionsToDropdown();
}
private void OnEnable()
{
if (_usesLocalizedOptions)
{
QueueGeneratedItemBinding();
}
}
private void OnDisable()
{
StopGeneratedItemBinding();
}
private void OnDestroy()
{
StopGeneratedItemBinding();
ClearLocalizedOptionDefinitions();
}
public int GetOptionIndex(string option)
{
return options.IndexOf(option);
}
/// <summary>
/// 只更新当前选择状态,不调用 <see cref="updateValueAction"/>。
/// 因此语言切换和 Dropdown 列表重建都不会重新应用玩家的设置。
/// </summary>
public void SetValue(int index)
{
selectedIndex = index;
if (options == null || options.Count == 0)
{
selectedIndex = 0;
selectedOption = string.Empty;
return;
}
selectedIndex = Mathf.Clamp(index, 0, options.Count - 1);
selectedOption = options[selectedIndex];
dropdown.selectedItemIndex = selectedIndex;
dropdown.UpdateItemLayout();
if (_usesLocalizedOptions)
{
BindSelectedLocalizedText();
}
else if (dropdown.selectedText != null)
{
dropdown.selectedText.text = selectedOption;
}
}
private void EnsureValueChangeListener()
{
if (_isValueChangeListenerRegistered)
{
return;
}
dropdown.onValueChanged.AddListener(OnDropdownValueChanged);
_isValueChangeListenerRegistered = true;
}
private void OnDropdownValueChanged(int index)
{
SetValue(index);
updateValueAction?.Invoke();
}
private void BuildLocalizedOptionPlaceholders()
{
options = new List<string>(_localizedOptions.Count);
for (int index = 0; index < _localizedOptions.Count; index++)
{
// Michsky 需要 itemName 创建 Item真正可见的内容会在创建完成后被 LocalizedTMPText 覆盖。
options.Add($"LocalizedOption_{index}");
}
}
private void ApplyOptionsToDropdown()
{
if (options == null || options.Count == 0)
{
dropdown.items = new List<CustomDropdown.Item>();
return;
}
SetValue(selectedIndex);
dropdown.items = new List<CustomDropdown.Item>(options.Count);
foreach (string option in options)
{
dropdown.items.Add(new CustomDropdown.Item { itemName = option });
}
if (dropdown.gameObject.activeInHierarchy)
{
if (dropdown.isOn)
{
dropdown.Animate();
}
dropdown.SetupDropdown();
}
else
{
dropdown.UpdateItemLayout();
}
if (_usesLocalizedOptions)
{
QueueGeneratedItemBinding();
}
}
/// <summary>
/// 等待一帧,确保 Michsky 已销毁旧 Item 并完成当前批次的实例化,再为每个 Item 绑定引用。
/// 这样不会保留已销毁 Item 的语言监听,也避免把引用绑定到上一批列表对象。
/// </summary>
private void QueueGeneratedItemBinding()
{
if (!isActiveAndEnabled || _bindGeneratedItemsCoroutine != null)
{
return;
}
_bindGeneratedItemsCoroutine = StartCoroutine(BindGeneratedItemsNextFrame());
}
private IEnumerator BindGeneratedItemsNextFrame()
{
yield return null;
_bindGeneratedItemsCoroutine = null;
BindSelectedLocalizedText();
if (dropdown.itemParent == null)
{
yield break;
}
int itemCount = Mathf.Min(dropdown.itemParent.childCount, _localizedOptions.Count);
for (int index = 0; index < itemCount; index++)
{
TMP_Text itemText = dropdown.itemParent.GetChild(index).GetComponentInChildren<TMP_Text>(true);
BindLocalizedText(itemText, _localizedOptions[index]);
}
}
private void BindSelectedLocalizedText()
{
if (!_usesLocalizedOptions || _localizedOptions.Count == 0)
{
return;
}
int optionIndex = Mathf.Clamp(selectedIndex, 0, _localizedOptions.Count - 1);
BindLocalizedText(dropdown.selectedText, _localizedOptions[optionIndex]);
}
private static void BindLocalizedText(TMP_Text text, LocalizedString reference)
{
if (text == null)
{
return;
}
LocalizedTMPText localizedText = text.GetComponent<LocalizedTMPText>();
if (localizedText == null)
{
localizedText = text.gameObject.AddComponent<LocalizedTMPText>();
}
localizedText.SetStringReference(reference);
}
/// <summary>
/// 将本 Dropdown 先前动态附加的本地化引用清空。
/// 同一控件改用运行时文本列表时必须执行此步骤,否则旧 Locale 回调会覆盖新的原始文本。
/// </summary>
private void ClearLocalizedBindings()
{
ClearLocalizedBinding(dropdown.selectedText);
if (dropdown.itemParent == null)
{
return;
}
foreach (Transform item in dropdown.itemParent)
{
ClearLocalizedBinding(item.GetComponentInChildren<TMP_Text>(true));
}
}
private static void ClearLocalizedBinding(TMP_Text text)
{
LocalizedTMPText localizedText = text == null ? null : text.GetComponent<LocalizedTMPText>();
if (localizedText != null)
{
localizedText.SetStringReference(null);
}
}
private void ClearLocalizedOptionDefinitions()
{
// LocalizedString 的订阅由每个 LocalizedTMPText 持有ClearLocalizedBindings 已经解除它们。
// 当前 Unity Localization 版本没有公开的 LocalizedString.Dispose因此清空运行时列表即可。
_localizedOptions.Clear();
}
private void StopGeneratedItemBinding()
{
if (_bindGeneratedItemsCoroutine == null)
{
return;
}
StopCoroutine(_bindGeneratedItemsCoroutine);
_bindGeneratedItemsCoroutine = null;
}
}
}

View File

@@ -17,10 +17,22 @@ namespace Ichni.UI
public TMP_Text keyText;
public GameObject waitingForInputCover;
public void SetUp(string title, string subtitle, string actionName, int bindingIndex)
private bool _isClickListenerRegistered;
/// <summary>
/// 初始化重绑定行为。标题和说明由 Prefab 上的 LocalizedTMPText 配置;
/// <paramref name="actionName"/> 仅用于定位 Input System Action不是本地化 Key。
/// </summary>
public void SetUp(string actionName, int bindingIndex)
{
base.SetUp(title, subtitle);
base.SetUp();
if (_isClickListenerRegistered)
{
return;
}
button.onClick.AddListener(() => StartRebinding(actionName, bindingIndex));
_isClickListenerRegistered = true;
}
// 开始重绑定流程
@@ -82,4 +94,4 @@ namespace Ichni.UI
}
}
}
}
}

View File

@@ -1,6 +1,3 @@
using System.Collections;
using System.Collections.Generic;
using I2.Loc;
using TMPro;
using UnityEngine;
using UnityEngine.Events;
@@ -10,9 +7,6 @@ namespace Ichni.UI
{
public abstract class SettingsUIElementBase : MonoBehaviour
{
public string title;
public string subTitle;
public Image titleBackground;
public TMP_Text titleText;
public TMP_Text subTitleText;
@@ -21,52 +15,22 @@ namespace Ichni.UI
/// <summary>
/// 初始化设置控件的标题区域。
/// <paramref name="preservePrefabTitle"/> 用于已经由 Unity Localization 直接驱动标题的控件:
/// 运行时代码不会覆盖或隐藏 Prefab 中的本地化标题。
/// <para>
/// 标题与说明不再由此基础类写入 I2 Key。请在对应 <see cref="TMP_Text"/> 上配置
/// <c>LocalizedTMPText</c>,使静态文案与控件逻辑完全解耦。
/// </para>
/// <paramref name="preservePrefabTitle"/> 为 true 时保留 Prefab 的标题区域;这是设置页面的默认行为。
/// </summary>
public virtual void SetUp(string title = "", string subTitle = "", bool preservePrefabTitle = false)
public virtual void SetUp(bool preservePrefabTitle = true)
{
if (preservePrefabTitle)
{
return;
}
if (title != "")
{
SetTitle(title, subTitle);
}
else
{
titleBackground.gameObject.SetActive(false);
titleText.gameObject.SetActive(false);
subTitleText.gameObject.SetActive(false);
}
}
public void SetTitle(string title, string subTitle = "")
{
this.title = title;
this.subTitle = subTitle;
titleText.gameObject.SetActive(true);
var titleLoc = titleText.GetComponent<Localize>();
if (titleLoc != null)
titleLoc.SetTerm(title);
else
titleText.text = title;
if (subTitle != "")
{
subTitleText.gameObject.SetActive(true);
var subLoc = subTitleText.GetComponent<Localize>();
if (subLoc != null)
subLoc.SetTerm(subTitle);
else
subTitleText.text = subTitle;
}
else
{
subTitleText.gameObject.SetActive(false);
}
titleBackground.gameObject.SetActive(false);
titleText.gameObject.SetActive(false);
subTitleText.gameObject.SetActive(false);
}
}
}

View File

@@ -6,20 +6,27 @@ namespace Ichni.UI
{
public bool value;
public SwitchManager switchManager;
private bool _isValueChangeListenerRegistered;
public void SetUp(bool initialValue, string title = "", bool preservePrefabTitle = false)
public void SetUp(bool initialValue, bool preservePrefabTitle = true)
{
base.SetUp(title, preservePrefabTitle: preservePrefabTitle);
base.SetUp(preservePrefabTitle);
switchManager.onValueChanged.AddListener(isOn =>
if (!_isValueChangeListenerRegistered)
{
value = isOn;
updateValueAction?.Invoke();
});
switchManager.onValueChanged.AddListener(OnSwitchValueChanged);
_isValueChangeListenerRegistered = true;
}
SetValue(initialValue);
}
private void OnSwitchValueChanged(bool isOn)
{
value = isOn;
updateValueAction?.Invoke();
}
public void SetValue(bool value)
{
this.value = value;

View File

@@ -1,7 +1,3 @@
using System.Collections;
using System.Collections.Generic;
using I2.Loc;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
@@ -10,25 +6,28 @@ namespace Ichni.UI
public class TextButton : SettingsUIElementBase
{
public Button button;
public TMP_Text buttonText;
public void SetUp(string title, string subTitle, string textContent)
private bool _isClickListenerRegistered;
/// <summary>
/// 初始化按钮逻辑。标题、说明及按钮文字均由 Prefab 上的 LocalizedTMPText 配置,
/// 不再把字符串当作 I2 的 Term 传入。
/// </summary>
public void SetUp()
{
base.SetUp(title, subTitle);
var localize = buttonText.GetComponent<Localize>();
if (localize != null)
base.SetUp();
if (_isClickListenerRegistered)
{
localize.SetTerm(textContent);
}
else
{
buttonText.text = textContent;
return;
}
button.onClick.AddListener(() =>
{
updateValueAction?.Invoke();
});
button.onClick.AddListener(InvokeUpdateValueAction);
_isClickListenerRegistered = true;
}
private void InvokeUpdateValueAction()
{
updateValueAction?.Invoke();
}
}
}
}

View File

@@ -2,7 +2,6 @@ using System;
using System.Collections;
using System.Collections.Generic;
using DG.Tweening;
using I2.Loc;
using TMPro;
using UnityEngine;
using UnityEngine.Events;
@@ -27,13 +26,13 @@ namespace Ichni.UI
public string suffix;
public bool showPositiveMark;
public void SetUp(int initialValue, int step, string title = "")
public void SetUp(int initialValue, int step)
{
intStep = step;
intMin = int.MinValue;
intMax = int.MaxValue;
base.SetUp(title);
base.SetUp();
increaseButton.SetUpButton(IncreaseValue, IncreaseValue);
decreaseButton.SetUpButton(DecreaseValue, DecreaseValue);
@@ -94,7 +93,7 @@ namespace Ichni.UI
{
valueText.text = prefix + (showPositiveMark && intValue > 0 ? "+" : "") + intValue.ToString() + suffix;
var target = new Vector2((float)(intValue - intMin) / (intMax - intMin) * 660, 20);
var target = new Vector2((float)(intValue - intMin) / (intMax - intMin) * 560, 20);
barImage.rectTransform.sizeDelta = target;
updateValueAction?.Invoke();
}
@@ -106,7 +105,7 @@ namespace Ichni.UI
if (LastValue == intValue) return;
tween?.Complete();
valueText.text = prefix + (showPositiveMark && intValue > 0 ? "+" : "") + intValue.ToString() + suffix;
var target = new Vector2((float)(intValue - intMin) / (intMax - intMin) * 660, 20);
var target = new Vector2((float)(intValue - intMin) / (intMax - intMin) * 560, 20);
tween = barImage.rectTransform.DOSizeDelta(target, 0.2f).SetEase(Ease.OutExpo).Play();
LastValue = intValue;
updateValueAction?.Invoke();
@@ -117,4 +116,4 @@ namespace Ichni.UI
return intValue;
}
}
}
}

View File

@@ -15,7 +15,7 @@ namespace Ichni.Menu
public override void Initialize()
{
mainVolumeModifier.SetUp(gameSettings.masterVolume, 10, "Menu UI/Settings_Master_Volume");
mainVolumeModifier.SetUp(gameSettings.masterVolume, 10);
mainVolumeModifier.SetMinMax(0, 100);
mainVolumeModifier.updateValueAction = () =>
{
@@ -23,7 +23,7 @@ namespace Ichni.Menu
SettingsManager.instance.ApplyAudioSettings();
};
musicVolumeModifier.SetUp(gameSettings.musicVolume, 10, "Menu UI/Settings_Music_Volume");
musicVolumeModifier.SetUp(gameSettings.musicVolume, 10);
musicVolumeModifier.SetMinMax(0, 100);
musicVolumeModifier.updateValueAction = () =>
{
@@ -31,7 +31,7 @@ namespace Ichni.Menu
SettingsManager.instance.ApplyAudioSettings();
};
sfxVolumeModifier.SetUp(gameSettings.soundEffectVolume, 10, "Menu UI/Settings_SFX_Volume");
sfxVolumeModifier.SetUp(gameSettings.soundEffectVolume, 10);
sfxVolumeModifier.SetMinMax(0, 100);
sfxVolumeModifier.updateValueAction = () =>
{
@@ -39,7 +39,7 @@ namespace Ichni.Menu
SettingsManager.instance.ApplyAudioSettings();
};
uiVolumeModifier.SetUp(gameSettings.uiVolume, 10, "Menu UI/Settings_UI_Volume");
uiVolumeModifier.SetUp(gameSettings.uiVolume, 10);
uiVolumeModifier.SetMinMax(0, 100);
uiVolumeModifier.updateValueAction = () =>
{
@@ -47,8 +47,7 @@ namespace Ichni.Menu
SettingsManager.instance.ApplyAudioSettings();
};
inGameAudioEffectsSwitch.SetUp(gameSettings.enableInGameAudioEffects,
"Menu UI/Settings_InGame_Audio_Effects");
inGameAudioEffectsSwitch.SetUp(gameSettings.enableInGameAudioEffects);
inGameAudioEffectsSwitch.updateValueAction = () =>
{
gameSettings.enableInGameAudioEffects = inGameAudioEffectsSwitch.GetValue();

View File

@@ -13,19 +13,19 @@ namespace Ichni.Menu
public override void Initialize()
{
debugModeSwitch.SetUp(gameSettings.debugMode, "Menu UI/Settings_Debug_Mode");
debugModeSwitch.SetUp(gameSettings.debugMode);
debugModeSwitch.updateValueAction = () =>
{
gameSettings.debugMode = debugModeSwitch.GetValue();
};
judgeTypeSwitch.SetUp(gameSettings.judgeType, "Menu UI/Settings_Judge_Type");
judgeTypeSwitch.SetUp(gameSettings.judgeType);
judgeTypeSwitch.updateValueAction = () =>
{
gameSettings.judgeType = judgeTypeSwitch.GetValue();
};
autoPlaySwitch.SetUp(gameSettings.autoPlay, "Menu UI/Settings_Auto_Play");
autoPlaySwitch.SetUp(gameSettings.autoPlay);
autoPlaySwitch.updateValueAction = () =>
{
gameSettings.autoPlay = autoPlaySwitch.GetValue();
@@ -39,4 +39,4 @@ namespace Ichni.Menu
autoPlaySwitch.SetValue(gameSettings.autoPlay);
}
}
}
}

View File

@@ -9,7 +9,7 @@ namespace Ichni.UI
public Dropdown languageDropdown;
public override void Initialize()
{
offsetEditorButton.SetUp("Menu UI/Settings_Offset_Editor", "", "Menu UI/Settings_Enter");
offsetEditorButton.SetUp();
offsetEditorButton.updateValueAction = () =>
{
gameObject.SetActive(false);
@@ -19,7 +19,10 @@ namespace Ichni.UI
MenuManager.instance.settingsUIPage.settingsWindowController.buttonsContainer.gameObject.SetActive(false);
};
languageDropdown.SetUp(SettingsManager.instance.GetLanguageDropdownIndex(), MenuManager.instance.displayLanguageList, "Menu UI/Settings_Language");
// 语言列表采用各语言的自称(例如日本語、한국어),不应随当前 Locale 再次翻译。
languageDropdown.SetUpRuntime(
SettingsManager.instance.GetLanguageDropdownIndex(),
MenuManager.instance.displayLanguageList);
languageDropdown.updateValueAction = () =>
{
SettingsManager.instance.SetLanguageByDropdownIndex(languageDropdown.selectedIndex);

View File

@@ -8,32 +8,34 @@ namespace Ichni.Menu
{
public class GraphicsSettingsWindow : SettingsWindow
{
private static readonly List<string> QualityPresetOptions = new List<string>
private const string UiTable = "UI";
private static readonly string[] QualityPresetKeys =
{
"Performant",
"Balanced",
"High Fidelity"
"ui_settings_quality_performance",
"ui_settings_quality_balanced",
"ui_settings_quality_high_fidelity"
};
private static readonly List<string> DisplayModeOptions = new List<string>
private static readonly string[] BloomQualityKeys =
{
"Fullscreen",
"Borderless Fullscreen",
"Windowed"
"ui_settings_bloom_off",
"ui_settings_bloom_performance",
"ui_settings_bloom_standard"
};
private static readonly List<string> BloomQualityOptions = new List<string>
private static readonly string[] VfxQualityKeys =
{
"Off",
"Performance",
"Standard"
"ui_settings_vfx_low",
"ui_settings_vfx_medium",
"ui_settings_vfx_high"
};
private static readonly List<string> VfxQualityOptions = new List<string>
private static readonly string[] DisplayModeKeys =
{
"Low",
"Medium",
"High"
"ui_settings_display_fullscreen",
"ui_settings_display_borderless_fullscreen",
"ui_settings_display_windowed"
};
private readonly List<Vector2Int> _availableDisplayResolutions = new List<Vector2Int>();
@@ -52,15 +54,14 @@ namespace Ichni.Menu
public override void Initialize()
{
qualityPresetDropdown.SetUp((int)gameSettings.graphicsQualityPreset, QualityPresetOptions,
"Menu UI/Settings_Quality_Preset");
qualityPresetDropdown.SetUpLocalized((int)gameSettings.graphicsQualityPreset, UiTable, QualityPresetKeys);
qualityPresetDropdown.updateValueAction = () =>
{
gameSettings.graphicsQualityPreset = (GraphicsQualityPreset)qualityPresetDropdown.selectedIndex;
SettingsManager.instance.ApplyGraphicSettings();
};
renderScaleModifier.SetUp(gameSettings.renderScaleLevel, 1, "Menu UI/Settings_Render_Scale");
renderScaleModifier.SetUp(gameSettings.renderScaleLevel, 1);
renderScaleModifier.SetMinMax(0, 5);
renderScaleModifier.updateValueAction = () =>
{
@@ -68,7 +69,7 @@ namespace Ichni.Menu
SettingsManager.instance.ApplyGraphicSettings();
};
targetFrameModifier.SetUp(gameSettings.targetFrame, 30, "Menu UI/Settings_Target_Frame");
targetFrameModifier.SetUp(gameSettings.targetFrame, 30);
targetFrameModifier.SetMinMax(30, 120);
targetFrameModifier.updateValueAction = () =>
{
@@ -119,21 +120,21 @@ namespace Ichni.Menu
return;
}
postProcessingSwitch.SetUp(gameSettings.enablePostProcessing, preservePrefabTitle: true);
postProcessingSwitch.SetUp(gameSettings.enablePostProcessing);
postProcessingSwitch.updateValueAction = () =>
{
gameSettings.enablePostProcessing = postProcessingSwitch.GetValue();
SettingsManager.instance.ApplyPostProcessingSettings();
};
bloomQualityDropdown.SetUp((int)gameSettings.bloomQuality, BloomQualityOptions, preservePrefabTitle: true);
bloomQualityDropdown.SetUpLocalized((int)gameSettings.bloomQuality, UiTable, BloomQualityKeys);
bloomQualityDropdown.updateValueAction = () =>
{
gameSettings.bloomQuality = (BloomQuality)bloomQualityDropdown.selectedIndex;
SettingsManager.instance.ApplyPostProcessingSettings();
};
vfxQualityDropdown.SetUp((int)gameSettings.vfxQuality, VfxQualityOptions, preservePrefabTitle: true);
vfxQualityDropdown.SetUpLocalized((int)gameSettings.vfxQuality, UiTable, VfxQualityKeys);
vfxQualityDropdown.updateValueAction = () =>
{
gameSettings.vfxQuality = (VfxQuality)vfxQualityDropdown.selectedIndex;
@@ -145,23 +146,21 @@ namespace Ichni.Menu
{
BuildDisplayResolutionOptions();
vSyncSwitch.SetUp(gameSettings.vSyncEnabled, "Menu UI/Settings_VSync");
vSyncSwitch.SetUp(gameSettings.vSyncEnabled);
vSyncSwitch.updateValueAction = () =>
{
gameSettings.vSyncEnabled = vSyncSwitch.GetValue();
SettingsManager.instance.ApplyDisplaySettings();
};
displayModeDropdown.SetUp((int)gameSettings.displayMode, DisplayModeOptions,
"Menu UI/Settings_Display_Mode");
displayModeDropdown.SetUpLocalized((int)gameSettings.displayMode, UiTable, DisplayModeKeys);
displayModeDropdown.updateValueAction = () =>
{
gameSettings.displayMode = (GraphicsDisplayMode)displayModeDropdown.selectedIndex;
SettingsManager.instance.ApplyDisplaySettings();
};
displayResolutionDropdown.SetUp(GetDisplayResolutionIndex(), _displayResolutionOptions,
"Menu UI/Settings_Display_Resolution");
displayResolutionDropdown.SetUpRuntime(GetDisplayResolutionIndex(), _displayResolutionOptions);
displayResolutionDropdown.updateValueAction = () =>
{
Vector2Int resolution = _availableDisplayResolutions[displayResolutionDropdown.selectedIndex];

View File

@@ -25,10 +25,10 @@ namespace Ichni.Menu
foreach (RebindActionData data in rebindActions["Keyboard"])
{
// 在C# 8.0+ 中,可以使用静态匿名函数来避免闭包分配
data.rebindButton.SetUp(data.title, data.subtitle, data.actionName, data.bindingIndex);
data.rebindButton.SetUp(data.actionName, data.bindingIndex);
}
resetButton.SetUp("Menu UI/Settings_ResetRebinding", "", "Menu UI/Settings_Confirm");
resetButton.SetUp();
resetButton.updateValueAction = ResetAllBindings;
}
@@ -114,9 +114,6 @@ namespace Ichni.Menu
[System.Serializable]
public class RebindActionData
{
public string title;
[FormerlySerializedAs("description")] public string subtitle;
[Tooltip("必须与Input Actions Asset中的Action名称完全匹配")]
public string actionName;
@@ -132,4 +129,4 @@ namespace Ichni.Menu
}
}
}
}