This commit is contained in:
SoulliesOfficial
2026-06-09 11:21:59 -04:00
parent 7c60c40d6b
commit 021e76efe7
493 changed files with 50500 additions and 2211 deletions

View File

@@ -29,7 +29,7 @@ namespace Ichni.RhythmGame
this.splineRenderer.doubleSided = true;
this.splineRenderer.clipFrom = 0;
this.splineRenderer.clipTo = 1;
this.splineRenderer.updateMethod = SplineUser.UpdateMethod.Update;
this.splineRenderer.updateMethod = SplineUser.UpdateMethod.LateUpdate;
this.meshRenderer.material = renderMaterial;
this.splineRenderer.color = Color.white;
this.uvRotation = 0f;

View File

@@ -28,7 +28,7 @@ namespace Ichni.RhythmGame
this.pathGenerator.doubleSided = true;
this.pathGenerator.clipFrom = 0;
this.pathGenerator.clipTo = 1;
this.pathGenerator.updateMethod = SplineUser.UpdateMethod.Update;
this.pathGenerator.updateMethod = SplineUser.UpdateMethod.LateUpdate;
this.meshRenderer.material = renderMaterial;
this.pathGenerator.color = Color.white;
this.uvRotation = 90f;

View File

@@ -27,7 +27,7 @@ namespace Ichni.RhythmGame
this.surface.doubleSided = true;
this.surface.clipFrom = 0;
this.surface.clipTo = 1;
this.surface.updateMethod = SplineUser.UpdateMethod.Update;
this.surface.updateMethod = SplineUser.UpdateMethod.LateUpdate;
this.meshRenderer.material = renderMaterial;
this.surface.color = Color.white;
this.surface.uvRotation = 90;

View File

@@ -29,7 +29,7 @@ namespace Ichni.RhythmGame
this.tubeGenerator.spline = track.trackPathSubmodule.path;
this.tubeGenerator.clipFrom = 0;
this.tubeGenerator.clipTo = 1;
this.tubeGenerator.updateMethod = SplineUser.UpdateMethod.Update;
this.tubeGenerator.updateMethod = SplineUser.UpdateMethod.LateUpdate;
this.meshRenderer.material = renderMaterial;
this.tubeGenerator.color = Color.white;
this.tubeGenerator.uvRotation = 90;

View File

@@ -0,0 +1,10 @@
using Sirenix.OdinInspector;
using UnityEngine;
namespace Ichni
{
public class AudioManager : SLSUtilities.WwiseAssistance.AudioManager
{
}
}

View File

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

View File

@@ -1,4 +1,4 @@
using I2.Loc;
//using I2.Loc;
using UnityEngine;
namespace SLSUtilities.General
@@ -7,10 +7,10 @@ namespace SLSUtilities.General
{
public static string Localize(this string original)
{
if (LocalizationManager.TryGetTranslation(original, out string translated))
/*if (LocalizationManager.TryGetTranslation(original, out string translated))
{
return translated;
}
}*/
return original;
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
using System.Collections.Generic;
using Sirenix.OdinInspector;
using UnityEngine;
namespace SLSUtilities.Narrative
{
[CreateAssetMenu(fileName = "New Character Data", menuName = "SLSUtilities/Story System/Character Data")]
public class CharacterData : SerializedScriptableObject
{
[TitleGroup("角色档案", "Yarn Spinner 角色视觉与差分配置", Alignment = TitleAlignments.Centered)]
[BoxGroup("角色档案/基础信息 (Basic Info)")]
[LabelText("角色名称 (Yarn 识别码)")]
[Tooltip("在 C# 逻辑与场景注册中使用的标准唯一英文 ID例如'Player' 或 'Guide')。")]
public string nameKey;
[BoxGroup("角色档案/基础信息 (Basic Info)")]
[LabelText("显示名称 (Display Name)")]
[Tooltip("在 Yarn 对话文本中显示的本地化名称(例如中文:'引导者')。用于将文本说话人匹配到标准英文 ID。")]
public List<string> alias;
[BoxGroup("角色档案/立绘差分 (Portraits)", centerLabel: true)]
[LabelText("默认立绘 (Default Portrait)")]
[PreviewField(70, ObjectFieldAlignment.Left)]
[Tooltip("当 Yarn 台词没有指定 #mood 标签时,显示的默认角色立绘。")]
public Sprite defaultPortrait;
[BoxGroup("角色档案/立绘差分 (Portraits)")]
[LabelText("表情差分库 (Mood Expressions)")]
[DictionaryDrawerSettings(KeyLabel = "表情标签 (如 Happy, Sad)", ValueLabel = "对应的立绘 (Sprite)", DisplayMode = DictionaryDisplayOptions.ExpandedFoldout)]
[Tooltip("在此配置各种表情对应的立绘。在 Yarn 中使用 #mood:标签名 来触发。")]
public Dictionary<string, Sprite> expressions = new Dictionary<string, Sprite>();
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8ad44d17e97acb747b3b7649aa6d3661

View File

@@ -0,0 +1,43 @@
using System.Collections.Generic;
using Sirenix.OdinInspector;
using UnityEngine;
using UnityEngine.Serialization;
namespace SLSUtilities.Narrative
{
[CreateAssetMenu(fileName = "New Keyword Data", menuName = "SLSUtilities/Story System/Keyword Data")]
public class KeywordData : SerializedScriptableObject
{
[TitleGroup("关键词档案", "剧情百科中的词条配置", Alignment = TitleAlignments.Centered)]
[BoxGroup("关键词档案/基础信息 (Basic Info)")]
[LabelText("关键词 (Primary Keyword)")]
[Tooltip("主要的关键词文本,将在台词中被自动识别并高亮。")]
public string keyword;
[BoxGroup("关键词档案/基础信息 (Basic Info)")]
[LabelText("别名 (Aliases)")]
[Tooltip("该关键词的其他写法或简称,同样会被自动识别。例如:'灵能者'的别名可以是'灵能师'、'Psion'。")]
public List<string> aliases = new List<string>();
[BoxGroup("关键词档案/词条内容 (Content)")]
[LabelText("解释文本 (Description)")]
[Tooltip("当玩家悬停时显示的解释内容。如果文本中包含其他已注册的关键词,会自动生成嵌套链接。")]
public string description;
/// <summary>
/// 返回所有可触发该词条的文本(主关键词 + 所有别名)。
/// </summary>
public IEnumerable<string> GetAllTriggerWords()
{
yield return keyword;
foreach (var alias in aliases)
{
if (!string.IsNullOrWhiteSpace(alias))
{
yield return alias;
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 088b2e3aa8e7aad43b9a0230097676de

View File

@@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using Sirenix.OdinInspector;
using UnityEngine;
namespace SLSUtilities.Narrative
{
/// <summary>
/// 一个 NarrativeEntry 对应一个“剧情触发源”(如一个 NPC、一个区域、一个道具
/// 它包含一个有序的条件→节点路由列表。
/// </summary>
[CreateAssetMenu(fileName = "NarrativeEntry", menuName = "SLSUtilities/Story System/Narrative Entry")]
public class NarrativeEntry : SerializedScriptableObject
{
[TitleGroup("Identity", "剧情触发源身份标识", Alignment = TitleAlignments.Centered)]
[Required]
[Tooltip("全局唯一标识,对应 NPC 或触发器的 storyId (如 'OldMan', 'Forest_Gate')")]
public string storyId;
[TitleGroup("Routing Rules", "路由规则表 (从上到下评估,首个满足的生效)", Alignment = TitleAlignments.Centered)]
[ListDrawerSettings(ShowPaging = true, NumberOfItemsPerPage = 10)]
public List<NarrativeRoute> routes = new List<NarrativeRoute>();
[TitleGroup("Fallback", "兜底处理 (当所有路由条件都不满足时播什么)", Alignment = TitleAlignments.Centered)]
[Tooltip("兜底节点(可为空,为空则不播放任何对话)")]
public string fallbackNode;
}
/// <summary>
/// 单条路由规则:描述备注 + 条件列表 + 目标 Yarn 节点。
/// </summary>
[Serializable]
public class NarrativeRoute
{
[LabelText("描述 (仅编辑器备注)")]
[Required]
[Tooltip("例如:'第一次见面'、'已购买提灯后'")]
public string editorNote;
[LabelText("条件列表 (全部满足才匹配)")]
[ListDrawerSettings(ShowIndexLabels = false)]
public List<NarrativeCondition> conditions = new List<NarrativeCondition>();
[LabelText("目标 Yarn 节点")]
[Required]
[Tooltip("满足上述条件时播放的 Yarn Node 名称 (如 'OldMan_FirstMeet')")]
public string targetNode;
}
/// <summary>
/// 单个变量匹配条件:变量类型 + 变量名 + 比较方式 + 目标值。
/// </summary>
[Serializable]
public class NarrativeCondition
{
public enum ConditionType
{
Bool,
Int,
Float,
String
}
public enum CompareOp
{
[LabelText("==")] Equal,
[LabelText("!=")] NotEqual,
[LabelText(">")] Greater,
[LabelText(">=")] GreaterOrEqual,
[LabelText("<")] Less,
[LabelText("<=")] LessOrEqual
}
[HorizontalGroup("Cond", Width = 70)]
[HideLabel]
public ConditionType type = ConditionType.Bool;
[HorizontalGroup("Cond")]
[HideLabel]
[Required]
[Tooltip("StorySystem 变量名 (如 'has_lantern')")]
public string key;
[HorizontalGroup("Cond", Width = 60)]
[HideLabel]
public CompareOp op = CompareOp.Equal;
[HorizontalGroup("Cond")]
[HideLabel]
[Required]
[Tooltip("对比的值,布尔值请填 true/false其他按相应格式填写")]
public string value;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7033ff7e5a062be4ca2763804709b342

View File

@@ -0,0 +1,181 @@
using System.Collections.Generic;
using Sirenix.OdinInspector;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace SLSUtilities.Narrative
{
[CreateAssetMenu(fileName = "Story Project Database", menuName = "SLSUtilities/Story System/Story Project Database")]
public class StoryProjectDatabase : SerializedScriptableObject
{
// 巧妙利用 Odin 的嵌套分组语法:"父分组/子分组"
// 这样就可以让 TitleGroup 作为父节点显示在最上方,而 BoxGroup 嵌套在其中,解决了 Title 被包裹在 Box 里的问题。
[TitleGroup("全局剧情数据库", "集中管理所有剧情相关数据的注册中心", Alignment = TitleAlignments.Centered)]
[ListDrawerSettings(ShowIndexLabels = true, ListElementLabelName = "nameKey")]
[BoxGroup("全局剧情数据库/角色档案 (Characters)")]
[LabelText("角色档案列表 (Character Profiles)")]
public List<CharacterData> characters = new List<CharacterData>();
[ListDrawerSettings(ShowIndexLabels = true)]
[BoxGroup("全局剧情数据库/变量数据 (Variables)")]
[LabelText("变量数据组 (Variable Groups)")]
public List<VariableData> variables = new List<VariableData>();
[ListDrawerSettings(ShowIndexLabels = true, ListElementLabelName = "keyword")]
[BoxGroup("全局剧情数据库/已注册的资产 (Registered Assets)")]
[LabelText("关键词词条 (Keywords)")]
public List<KeywordData> keywords = new List<KeywordData>();
[ListDrawerSettings(ShowIndexLabels = true, ListElementLabelName = "storyId")]
[BoxGroup("全局剧情数据库/剧情入口 (Narrative Entries)")]
[LabelText("剧情入口路由表 (Narrative Entries)")]
public List<NarrativeEntry> narrativeEntries = new List<NarrativeEntry>();
[TitleGroup("Yarn File Export Settings", "NPC 变量定义文件导出配置", Alignment = TitleAlignments.Centered)]
[FolderPath]
[Required("请指定 Yarn 文件的导出目标文件夹!")]
[Tooltip("生成的 NPC_IDs.yarn 文件的保存目录(建议放在 Yarn 脚本文件夹下)")]
public string exportFolder = "Assets/Story";
[Button("生成 NPC_IDs.yarn (Generate)", ButtonSizes.Medium)]
[GUIColor(0.2f, 0.8f, 0.4f)]
public void GenerateNpcIdsYarnFile()
{
if (string.IsNullOrEmpty(exportFolder))
{
Debug.LogError("[StoryProjectDatabase] 导出失败:未指定有效的导出文件夹路径!");
return;
}
if (characters == null || characters.Count == 0)
{
Debug.LogWarning("[StoryProjectDatabase] 角色列表为空,已取消生成。");
return;
}
// 确保目标导出目录存在
if (!System.IO.Directory.Exists(exportFolder))
{
try
{
System.IO.Directory.CreateDirectory(exportFolder);
}
catch (System.Exception ex)
{
Debug.LogError($"[StoryProjectDatabase] 无法创建目标文件夹 '{exportFolder}': {ex.Message}");
return;
}
}
string filePath = System.IO.Path.Combine(exportFolder, "NPC_IDs.yarn");
try
{
using (System.IO.StreamWriter writer = new System.IO.StreamWriter(filePath, false, System.Text.Encoding.UTF8))
{
writer.WriteLine("// ===========================================================================");
writer.WriteLine("// 自动生成的 NPC 英文标准名 ID 变量定义文件。");
writer.WriteLine("// 供 VS Code Yarn Spinner 插件进行命令变量补全(例如:输入 $NPC_ 触发提示)。");
writer.WriteLine("// 警告:该文件为程序自动生成,请勿在此文件中手动编辑或添加内容。");
writer.WriteLine("// ===========================================================================");
writer.WriteLine();
// 必须将 declare 声明置于有效的 Node 结构内,防止编译器在解析无 Node 的文件时抛出 Token 识别错误token recognition error at: '<'
writer.WriteLine("title: NPC_IDs");
writer.WriteLine("---");
foreach (var charData in characters)
{
if (charData == null || string.IsNullOrWhiteSpace(charData.nameKey)) continue;
string trimmedId = charData.nameKey.Trim();
// 生成格式如:<<declare $NPC_OldMan = "OldMan">>
writer.WriteLine($"<<declare $NPC_{trimmedId} = \"{trimmedId}\">>");
}
writer.WriteLine("===");
}
#if UNITY_EDITOR
// 刷新 AssetDatabase让 Unity 编辑器立刻加载生成的 .yarn 资源
UnityEditor.AssetDatabase.Refresh();
#endif
Debug.Log($"[StoryProjectDatabase] 成功生成/更新 NPC 声明文件: '{filePath}'");
}
catch (System.Exception ex)
{
Debug.LogError($"[StoryProjectDatabase] 导出 NPC_IDs.yarn 失败: {ex.Message}");
}
}
[Button("自动扫描并注册数据 (Auto-Scan Directory)", ButtonSizes.Large, Icon = SdfIconType.Search)]
[GUIColor(0.4f, 0.8f, 1f)]
[PropertyTooltip("自动在当前数据库所在的文件夹(及其子文件夹)中寻找所有的 CharacterData、VariableData、KeywordData 和 NarrativeEntry并自动填入上方的列表中。")]
public void AutoPopulate()
{
#if UNITY_EDITOR
// 获取当前 Database 资产所在的目录路径
string dbPath = AssetDatabase.GetAssetPath(this);
if (string.IsNullOrEmpty(dbPath))
{
Debug.LogWarning("[StorySystem] 请先将数据库资产 (Database) 保存到项目目录中。");
return;
}
string searchDirectory = System.IO.Path.GetDirectoryName(dbPath);
// 搜索 CharacterData
string[] charGuids = AssetDatabase.FindAssets($"t:{nameof(CharacterData)}", new[] { searchDirectory });
characters.Clear();
foreach (var guid in charGuids)
{
var assetPath = AssetDatabase.GUIDToAssetPath(guid);
var charData = AssetDatabase.LoadAssetAtPath<CharacterData>(assetPath);
if (charData != null && !characters.Contains(charData))
characters.Add(charData);
}
// 搜索 VariableData
string[] varGuids = AssetDatabase.FindAssets($"t:{nameof(VariableData)}", new[] { searchDirectory });
variables.Clear();
foreach (var guid in varGuids)
{
var assetPath = AssetDatabase.GUIDToAssetPath(guid);
var varData = AssetDatabase.LoadAssetAtPath<VariableData>(assetPath);
if (varData != null && !variables.Contains(varData))
variables.Add(varData);
}
// 搜索 KeywordData
string[] kwGuids = AssetDatabase.FindAssets($"t:{nameof(KeywordData)}", new[] { searchDirectory });
keywords.Clear();
foreach (var guid in kwGuids)
{
var assetPath = AssetDatabase.GUIDToAssetPath(guid);
var kwData = AssetDatabase.LoadAssetAtPath<KeywordData>(assetPath);
if (kwData != null && !keywords.Contains(kwData))
keywords.Add(kwData);
}
// 搜索 NarrativeEntry
string[] entryGuids = AssetDatabase.FindAssets($"t:{nameof(NarrativeEntry)}", new[] { searchDirectory });
narrativeEntries.Clear();
foreach (var guid in entryGuids)
{
var assetPath = AssetDatabase.GUIDToAssetPath(guid);
var entryData = AssetDatabase.LoadAssetAtPath<NarrativeEntry>(assetPath);
if (entryData != null && !narrativeEntries.Contains(entryData))
narrativeEntries.Add(entryData);
}
EditorUtility.SetDirty(this);
AssetDatabase.SaveAssets();
Debug.Log($"[StorySystem] 自动扫描完成!共注册了 {characters.Count} 个角色档案、{variables.Count} 个变量数据组、{keywords.Count} 个关键词词条 和 {narrativeEntries.Count} 个剧情入口路由表。");
#else
Debug.LogWarning("自动扫描 (AutoPopulate) 只能在 Unity 编辑器环境下运行。");
#endif
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7e578280b10e24b40859914cec13cf78

View File

@@ -0,0 +1,24 @@
using System.Collections.Generic;
using Sirenix.OdinInspector;
using UnityEngine;
namespace SLSUtilities.Narrative
{
[CreateAssetMenu(fileName = "StoryVariableData", menuName = "SLSUtilities/Story System/Variable Data")]
public class VariableData : SerializedScriptableObject
{
[Title("变量数据", titleAlignment: TitleAlignments.Centered)]
[Searchable]
public Dictionary<string, bool> boolVariables = new Dictionary<string, bool>();
[Searchable]
public Dictionary<string, int> intVariables = new Dictionary<string, int>();
[Searchable]
public Dictionary<string, float> floatVariables = new Dictionary<string, float>();
[Searchable]
public Dictionary<string, string> stringVariables = new Dictionary<string, string>();
}
}

View File

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

View File

@@ -0,0 +1,173 @@
using System.Collections.Generic;
using UnityEngine;
namespace SLSUtilities.Narrative
{
/// <summary>
/// 条件匹配评估工具类。
/// 读取并比对 StorySystem.Variables 中的运行时状态,支持多种类型的变量与运算符。
/// </summary>
public static class NarrativeConditionEvaluator
{
/// <summary>
/// 评估一系列条件是否全部满足 (AND 关系)。
/// </summary>
public static bool Evaluate(List<NarrativeCondition> conditions)
{
if (conditions == null || conditions.Count == 0)
return true; // 无条件默认满足
if (StorySystem.Variables == null)
{
Debug.LogWarning("[StorySystem] 评估条件失败StorySystem.Variables 尚未初始化。");
return false;
}
foreach (var cond in conditions)
{
if (string.IsNullOrEmpty(cond.key))
{
Debug.LogWarning("[StorySystem] 条件配置错误:变量 key 为空。");
return false;
}
if (!EvaluateCondition(cond))
return false; // 任何一个条件不满足,直接返回 false
}
return true;
}
private static bool EvaluateCondition(NarrativeCondition cond)
{
switch (cond.type)
{
case NarrativeCondition.ConditionType.Bool:
return EvaluateBool(cond);
case NarrativeCondition.ConditionType.Int:
return EvaluateInt(cond);
case NarrativeCondition.ConditionType.Float:
return EvaluateFloat(cond);
case NarrativeCondition.ConditionType.String:
return EvaluateString(cond);
default:
Debug.LogWarning($"[StorySystem] 未知的条件类型: {cond.type}");
return false;
}
}
private static bool EvaluateBool(NarrativeCondition cond)
{
bool currentVal = false;
if (!StorySystem.Variables.boolVariables.TryGetValue(cond.key, out currentVal))
{
Debug.LogWarning($"[StorySystem] 变量未找到: Bool '{cond.key}',使用默认值 false 匹配。");
}
if (!bool.TryParse(cond.value, out bool targetVal))
{
Debug.LogError($"[StorySystem] 无法将目标值 '{cond.value}' 解析为 Bool。条件键: {cond.key}");
return false;
}
switch (cond.op)
{
case NarrativeCondition.CompareOp.Equal:
return currentVal == targetVal;
case NarrativeCondition.CompareOp.NotEqual:
return currentVal != targetVal;
default:
Debug.LogWarning($"[StorySystem] 布尔类型不支持比较运算符: {cond.op},必须为 Equal 或 NotEqual。");
return false;
}
}
private static bool EvaluateInt(NarrativeCondition cond)
{
int currentVal = 0;
if (!StorySystem.Variables.intVariables.TryGetValue(cond.key, out currentVal))
{
Debug.LogWarning($"[StorySystem] 变量未找到: Int '{cond.key}',使用默认值 0 匹配。");
}
if (!int.TryParse(cond.value, out int targetVal))
{
Debug.LogError($"[StorySystem] 无法将目标值 '{cond.value}' 解析为 Int。条件键: {cond.key}");
return false;
}
switch (cond.op)
{
case NarrativeCondition.CompareOp.Equal:
return currentVal == targetVal;
case NarrativeCondition.CompareOp.NotEqual:
return currentVal != targetVal;
case NarrativeCondition.CompareOp.Greater:
return currentVal > targetVal;
case NarrativeCondition.CompareOp.GreaterOrEqual:
return currentVal >= targetVal;
case NarrativeCondition.CompareOp.Less:
return currentVal < targetVal;
case NarrativeCondition.CompareOp.LessOrEqual:
return currentVal <= targetVal;
default:
return false;
}
}
private static bool EvaluateFloat(NarrativeCondition cond)
{
float currentVal = 0f;
if (!StorySystem.Variables.floatVariables.TryGetValue(cond.key, out currentVal))
{
Debug.LogWarning($"[StorySystem] 变量未找到: Float '{cond.key}',使用默认值 0.0 匹配。");
}
if (!float.TryParse(cond.value, out float targetVal))
{
Debug.LogError($"[StorySystem] 无法将目标值 '{cond.value}' 解析为 Float。条件键: {cond.key}");
return false;
}
switch (cond.op)
{
case NarrativeCondition.CompareOp.Equal:
return Mathf.Approximately(currentVal, targetVal);
case NarrativeCondition.CompareOp.NotEqual:
return !Mathf.Approximately(currentVal, targetVal);
case NarrativeCondition.CompareOp.Greater:
return currentVal > targetVal;
case NarrativeCondition.CompareOp.GreaterOrEqual:
return currentVal >= targetVal;
case NarrativeCondition.CompareOp.Less:
return currentVal < targetVal;
case NarrativeCondition.CompareOp.LessOrEqual:
return currentVal <= targetVal;
default:
return false;
}
}
private static bool EvaluateString(NarrativeCondition cond)
{
string currentVal = string.Empty;
if (!StorySystem.Variables.stringVariables.TryGetValue(cond.key, out currentVal))
{
Debug.LogWarning($"[StorySystem] 变量未找到: String '{cond.key}',使用默认空字符串匹配。");
}
string targetVal = cond.value ?? string.Empty;
switch (cond.op)
{
case NarrativeCondition.CompareOp.Equal:
return currentVal == targetVal;
case NarrativeCondition.CompareOp.NotEqual:
return currentVal != targetVal;
default:
Debug.LogWarning($"[StorySystem] 字符串类型不支持比较运算符: {cond.op},必须为 Equal 或 NotEqual。");
return false;
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1021ec61ce2442d49bb15ecfe6dd73e9

View File

@@ -0,0 +1,75 @@
using Sirenix.OdinInspector;
using UnityEngine;
namespace SLSUtilities.Narrative
{
/// <summary>
/// 通用剧情触发器基类。
/// 仅负责管理触发状态oneShot, hasFired与全局故事 IDstoryId
/// 具体触发的时机与条件由派生类(子类)自行重写并定义。
/// </summary>
public abstract class NarrativeTrigger : MonoBehaviour
{
/// <summary>
/// 全局静态触发事件。当任何剧情触发器被激活时触发。
/// 用于彻底解耦 SLSUtilities 核心程序集与 MainGame 层的 StoryDirector。
/// </summary>
public static System.Action<string> OnNarrativeTriggerFired;
[TitleGroup("Trigger Settings", "剧情触发器核心配置", Alignment = TitleAlignments.Centered)]
[SerializeField]
[Required]
[Tooltip("要触发的 NarrativeEntry 的 storyId")]
protected string storyId;
[SerializeField]
[Tooltip("是否仅能触发一次")]
protected bool oneShot = true;
[ShowInInspector]
[ReadOnly]
[Tooltip("该触发器当前是否已经激活过")]
protected bool hasFired;
/// <summary>获取触发的目标故事 ID。</summary>
public string StoryId => storyId;
/// <summary>是否是一次性触发器。</summary>
public bool OneShot => oneShot;
/// <summary>该触发器是否已经激活过。</summary>
public bool HasFired => hasFired;
/// <summary>
/// 激活触发器,通知订阅者启动剧情。
/// </summary>
[Button("测试触发 (Fire)", ButtonSizes.Small)]
[GUIColor(0.3f, 0.8f, 1f)]
public virtual void Fire()
{
if (oneShot && hasFired)
{
return;
}
if (string.IsNullOrEmpty(storyId))
{
Debug.LogWarning($"[NarrativeTrigger] {gameObject.name} 触发失败:未配置 storyId。");
return;
}
hasFired = true;
OnNarrativeTriggerFired?.Invoke(storyId);
}
/// <summary>
/// 重置触发状态(允许在 oneShot 模式下重新触发)。
/// </summary>
[Button("重置激活状态 (Reset)", ButtonSizes.Small)]
public virtual void ResetTrigger()
{
hasFired = false;
}
}
}

View File

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

View File

@@ -0,0 +1,102 @@
using System.Collections.Generic;
using Sirenix.OdinInspector;
using SLSUtilities.General;
using UnityEngine;
namespace SLSUtilities.Narrative
{
public partial class StorySystem : Singleton<StorySystem>
{
public StoryProjectDatabase database;
public static VariableCollection Variables;
protected override void Awake()
{
Initialize(true);
LoadVariables();
}
}
public partial class StorySystem
{
public static StoryProjectDatabase Database => instance.database;
}
public partial class StorySystem
{
private static string SavePath => Application.persistentDataPath + "/Story/";
public class VariableCollection
{
public Dictionary<string, bool> boolVariables = new Dictionary<string, bool>();
public Dictionary<string, int> intVariables = new Dictionary<string, int>();
public Dictionary<string, float> floatVariables = new Dictionary<string, float>();
public Dictionary<string, string> stringVariables = new Dictionary<string, string>();
public void LoadFromData()
{
List<VariableData> variableDataList = Database.variables;
foreach (VariableData variableData in variableDataList)
{
foreach (KeyValuePair<string, bool> boolVar in variableData.boolVariables)
{
if(!boolVariables.TryAdd(boolVar.Key, boolVar.Value))
{
Debug.LogWarning($"[StorySystem] 变量加载警告:布尔变量 '{boolVar.Key}' 已存在,跳过重复项。");
}
}
foreach (KeyValuePair<string, int> intVar in variableData.intVariables)
{
if(!intVariables.TryAdd(intVar.Key, intVar.Value))
{
Debug.LogWarning($"[StorySystem] 变量加载警告:整数变量 '{intVar.Key}' 已存在,跳过重复项。");
}
}
foreach (KeyValuePair<string, float> floatVar in variableData.floatVariables)
{
if(!floatVariables.TryAdd(floatVar.Key, floatVar.Value))
{
Debug.LogWarning($"[StorySystem] 变量加载警告:浮点变量 '{floatVar.Key}' 已存在,跳过重复项。");
}
}
foreach (KeyValuePair<string, string> stringVar in variableData.stringVariables)
{
if (!stringVariables.TryAdd(stringVar.Key, stringVar.Value))
{
Debug.LogWarning($"[StorySystem] 变量加载警告:字符串变量 '{stringVar.Key}' 已存在,跳过重复项。");
}
}
}
}
}
[Button("保存变量数据 (Save Variables)", ButtonSizes.Small, Icon = SdfIconType.Save)]
public void SaveVariables()
{
string variablesSavePath = SavePath + "variables.json";
ES3.Save("Variables", Variables, variablesSavePath);
}
public void LoadVariables()
{
string variablesSavePath = SavePath + "variables.json";
if (!ES3.FileExists(variablesSavePath))
{
Variables = new VariableCollection();
Variables.LoadFromData();
ES3.Save("Variables", Variables, variablesSavePath);
}
else
{
Variables = ES3.Load<VariableCollection>("Variables", variablesSavePath);
}
}
}
}

View File

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

View File

@@ -0,0 +1,95 @@
using System.Collections.Generic;
using Yarn.Unity;
namespace SLSUtilities.Narrative
{
public static partial class YarnFunctions
{
[YarnCommand("set_bool")]
public static void Yarn_SetBool(string key, bool value)
{
StorySystem.Variables.boolVariables[key] = value;
}
[YarnFunction("get_bool")]
public static bool Yarn_GetBool(string key)
{
return StorySystem.Variables.boolVariables.GetValueOrDefault(key, false);
}
[YarnCommand("set_int")]
public static void Yarn_SetInt(string key, int value)
{
StorySystem.Variables.intVariables[key] = value;
}
[YarnCommand("modify_int")]
public static void Yarn_ModifyInt(string key, int modification)
{
int currentValue = StorySystem.Variables.intVariables.GetValueOrDefault(key, 0);
StorySystem.Variables.intVariables[key] = currentValue + modification;
}
[YarnFunction("get_int")]
public static int Yarn_GetInt(string key)
{
return StorySystem.Variables.intVariables.GetValueOrDefault(key, 0);
}
[YarnCommand("set_float")]
public static void Yarn_SetFloat(string key, float value)
{
StorySystem.Variables.floatVariables[key] = value;
}
[YarnCommand("modify_float")]
public static void Yarn_ModifyFloat(string key, float modification)
{
float currentValue = StorySystem.Variables.floatVariables.GetValueOrDefault(key, 0f);
StorySystem.Variables.floatVariables[key] = currentValue + modification;
}
[YarnFunction("get_float")]
public static float Yarn_GetFloat(string key)
{
return StorySystem.Variables.floatVariables.GetValueOrDefault(key, 0f);
}
[YarnCommand("set_string")]
public static void Yarn_SetString(string key, string value)
{
StorySystem.Variables.stringVariables[key] = value;
}
[YarnFunction("get_string")]
public static string Yarn_GetString(string key)
{
return StorySystem.Variables.stringVariables.GetValueOrDefault(key, "");
}
}
public static partial class YarnFunctions
{
[YarnCommand("log")]
public static void Log(string message, string logType)
{
logType = logType.ToLower();
switch (logType)
{
case "info":
UnityEngine.Debug.Log(message);
break;
case "warning":
UnityEngine.Debug.LogWarning(message);
break;
case "error":
UnityEngine.Debug.LogError(message);
break;
default:
UnityEngine.Debug.Log(message);
break;
}
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
using SLSUtilities.Narrative.UI;
using UnityEditor;
using Yarn.Unity.Editor;
namespace SLSUtilities.Narrative.Editor
{
/// <summary>
/// AdvancedLinePresenter 的自定义 Inspector。
/// 继承自 Yarn 的 YarnEditor完整复现 LinePresenter 的
/// [ShowIf]、[Group]、[MustNotBeNull] 等属性驱动的显示效果。
/// </summary>
[CanEditMultipleObjects]
[CustomEditor(typeof(AdvancedLinePresenter))]
public class AdvancedLinePresenterEditor : YarnEditor { }
/// <summary>
/// AdvancedLineAdvancer 的自定义 Inspector。
/// 继承自 Yarn 的 YarnEditor完整复现 LineAdvancer 的
/// InputMode 条件显示等效果。
/// </summary>
[CanEditMultipleObjects]
[CustomEditor(typeof(AdvancedLineAdvancer))]
public class AdvancedLineAdvancerEditor : YarnEditor { }
/// <summary>
/// AdvancedOptionsPresenter 的自定义 Inspector。
/// 完整复现 OptionsPresenter 的属性驱动效果。
/// </summary>
[CanEditMultipleObjects]
[CustomEditor(typeof(AdvancedOptionsPresenter))]
public class AdvancedOptionsPresenterEditor : YarnEditor { }
}

View File

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

View File

@@ -0,0 +1,21 @@
{
"name": "SLSUtilities.StorySystem.Editor",
"rootNamespace": "",
"references": [
"GUID:43bb4e992d9b32b4bbb25402b41e80a0",
"GUID:22a86856172c06146a539eeb9c9c67f5",
"GUID:34aa492b82754644eac2f903cd496268",
"GUID:3a299c53e4c683b4eb4a04c9ad9e648f"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 1f17ca624957b754baae4c01af60c96e
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,92 @@
using System.IO;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using Newtonsoft.Json.Linq;
namespace SLSUtilities.Narrative.Editor
{
/// <summary>
/// Yarn Spinner 自动 YSLS 合并器。
/// 自动将来自不同程序集Assembly-CSharp、SLSUtilities等生成的多个 YSLS 声明文件
/// 合并成一个单一的 combined.ysls.json 文件,输出到项目根目录下,
/// 从而完美解决 VS Code 中 Yarn Spinner 插件只能同时加载一个 definitions 文件的限制!
/// </summary>
public static class YslsMerger
{
[MenuItem("Tools/Yarn Spinner/Merge YSLS Files")]
public static void MergeYslsFiles()
{
string packagePath = Path.Combine(Directory.GetCurrentDirectory(), "ProjectSettings", "Packages", "dev.yarnspinner");
if (!Directory.Exists(packagePath))
{
return;
}
string combinedFileName = "combined.ysls.json";
string outputRootPath = Path.Combine(packagePath, combinedFileName);
try
{
// 搜索所有以 -generated.ysls.json 结尾的声明文件
string[] files = Directory.GetFiles(packagePath, "*-generated.ysls.json");
if (files.Length == 0)
{
return;
}
JArray allCommands = new JArray();
JArray allFunctions = new JArray();
int version = 2;
foreach (string file in files)
{
if (Path.GetFileName(file) == combinedFileName) continue;
string json = File.ReadAllText(file);
if (string.IsNullOrEmpty(json)) continue;
JObject root = JObject.Parse(json);
if (root.TryGetValue("version", out JToken vToken))
{
version = Mathf.Max(version, vToken.Value<int>());
}
if (root.TryGetValue("commands", out JToken cToken) && cToken is JArray commandsArray)
{
foreach (var cmd in commandsArray)
{
allCommands.Add(cmd);
}
}
if (root.TryGetValue("functions", out JToken fToken) && fToken is JArray functionsArray)
{
foreach (var func in functionsArray)
{
allFunctions.Add(func);
}
}
}
// 组装合并后的 JObject
JObject combined = new JObject
{
["version"] = version,
["commands"] = allCommands,
["functions"] = allFunctions
};
// 写入项目根目录
File.WriteAllText(outputRootPath, combined.ToString(Newtonsoft.Json.Formatting.Indented));
Debug.Log($"[YSLS Merger] 成功将 {files.Length} 个 YSLS 声明文件合并至: '{outputRootPath}'。现在您可以在 VS Code 中一键加载此合并文件!");
}
catch (System.Exception ex)
{
// 自动合并失败通常发生在初始无缓存时,作警告处理,不中断编辑体验
Debug.LogWarning($"[YSLS Merger] 自动合并 YSLS 文件失败(这通常是由于 Yarn Spinner 尚未生成初始的 ysls 缓存文件导致的,属于正常现象):{ex.Message}");
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7178a694f7924194ea3b787839ddd162

View File

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

View File

@@ -0,0 +1,857 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &822921496015337489
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 2451722883515327516}
- component: {fileID: 5821298512207332447}
- component: {fileID: 8003291120468737892}
- component: {fileID: 8538959725662554642}
m_Layer: 5
m_Name: PinIndicatorNoTitle
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &2451722883515327516
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 822921496015337489}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 1735022670865247660}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 1, y: 1}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: -10, y: -10}
m_SizeDelta: {x: 15, y: 15}
m_Pivot: {x: 0.5, y: 0.49999997}
--- !u!222 &5821298512207332447
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 822921496015337489}
m_CullTransparentMesh: 1
--- !u!114 &8003291120468737892
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 822921496015337489}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 21300000, guid: c6dee682f807c194aba431fb54cc6d7d, type: 3}
m_Type: 0
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!114 &8538959725662554642
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 822921496015337489}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.LayoutElement
m_IgnoreLayout: 1
m_MinWidth: -1
m_MinHeight: -1
m_PreferredWidth: -1
m_PreferredHeight: -1
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &919898932274239217
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 8932679771255679868}
- component: {fileID: 2302293284727336602}
- component: {fileID: 2108095605505189335}
m_Layer: 5
m_Name: TitleText
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &8932679771255679868
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 919898932274239217}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 4204169304323323032}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0.5}
m_AnchorMax: {x: 1, y: 0.5}
m_AnchoredPosition: {x: 10, y: 0}
m_SizeDelta: {x: -75, y: 25}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &2302293284727336602
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 919898932274239217}
m_CullTransparentMesh: 1
--- !u!114 &2108095605505189335
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 919898932274239217}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TextMeshProUGUI
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: Title
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: ac4df5038ff71a34a993c7c9fa7316fd, type: 2}
m_sharedMaterial: {fileID: 5688540820414838853, guid: ac4df5038ff71a34a993c7c9fa7316fd, type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4294967295
m_fontColor: {r: 1, g: 1, b: 1, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 24
m_fontSizeBase: 24
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 1
m_HorizontalAlignment: 1
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 0
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 0
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: 0, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!1 &1288486415013319065
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 5727255906185222562}
- component: {fileID: 2084483938339261636}
- component: {fileID: 1815344004216971213}
m_Layer: 5
m_Name: PinIndicator
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &5727255906185222562
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1288486415013319065}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 4204169304323323032}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 1, y: 0.5}
m_AnchorMax: {x: 1, y: 0.5}
m_AnchoredPosition: {x: -15, y: 0}
m_SizeDelta: {x: 25, y: 25}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &2084483938339261636
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1288486415013319065}
m_CullTransparentMesh: 1
--- !u!114 &1815344004216971213
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1288486415013319065}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 21300000, guid: c6dee682f807c194aba431fb54cc6d7d, type: 3}
m_Type: 0
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!1 &1770448389014005609
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 4204169304323323032}
m_Layer: 5
m_Name: TitleBar
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &4204169304323323032
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1770448389014005609}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 4664076838088352918}
- {fileID: 8932679771255679868}
- {fileID: 259397633913720669}
- {fileID: 5727255906185222562}
m_Father: {fileID: 332895709820465634}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 300, y: 30}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!1 &2304233746971670081
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 4664076838088352918}
- component: {fileID: 1051253791116523245}
- component: {fileID: 5038012897209242806}
m_Layer: 5
m_Name: icon
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &4664076838088352918
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2304233746971670081}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 4204169304323323032}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0.5}
m_AnchorMax: {x: 0, y: 0.5}
m_AnchoredPosition: {x: 25, y: 0}
m_SizeDelta: {x: 25, y: 25}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &1051253791116523245
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2304233746971670081}
m_CullTransparentMesh: 1
--- !u!114 &5038012897209242806
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2304233746971670081}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 21300000, guid: b72c64868a74dd04e9b605a346744fb5, type: 3}
m_Type: 0
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!1 &3156910686184609508
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 332895709820465634}
- component: {fileID: 945386332789889429}
- component: {fileID: 7828897958224438855}
- component: {fileID: 8391361806937792526}
- component: {fileID: 5156175450215433438}
- component: {fileID: 7593135083786652588}
m_Layer: 5
m_Name: TooltipPanel
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &332895709820465634
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3156910686184609508}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 4204169304323323032}
- {fileID: 1735022670865247660}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &945386332789889429
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3156910686184609508}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: b9480658e91a4d548a18e05958b5605f, type: 3}
m_Name:
m_EditorClassIdentifier: SLSUtilities.StorySystem::SLSUtilities.StorySystem.UI.KeywordPanel
titleBarContainer: {fileID: 4204169304323323032}
titleText: {fileID: 2108095605505189335}
iconImage: {fileID: 5038012897209242806}
titlePin: {fileID: 1288486415013319065}
descriptionContainer: {fileID: 1735022670865247660}
descriptionText: {fileID: 284395028850057396}
descriptionPin: {fileID: 822921496015337489}
--- !u!114 &7828897958224438855
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3156910686184609508}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 59f8146938fff824cb5fd77236b75775, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.VerticalLayoutGroup
m_Padding:
m_Left: 0
m_Right: 0
m_Top: 0
m_Bottom: 0
m_ChildAlignment: 1
m_Spacing: 0
m_ChildForceExpandWidth: 0
m_ChildForceExpandHeight: 0
m_ChildControlWidth: 0
m_ChildControlHeight: 0
m_ChildScaleWidth: 0
m_ChildScaleHeight: 0
m_ReverseArrangement: 0
--- !u!222 &8391361806937792526
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3156910686184609508}
m_CullTransparentMesh: 1
--- !u!114 &5156175450215433438
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3156910686184609508}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
m_Material: {fileID: 0}
m_Color: {r: 0.25, g: 0.25, b: 0.25, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 21300000, guid: cca3ca807d25c1844b79eeb77e944d05, type: 3}
m_Type: 1
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 8
--- !u!114 &7593135083786652588
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3156910686184609508}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 3245ec927659c4140ac4f8d17403cc18, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.ContentSizeFitter
m_HorizontalFit: 2
m_VerticalFit: 1
--- !u!1 &3804837773971309440
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1735022670865247660}
- component: {fileID: 7312414472714823656}
- component: {fileID: 5296572890931267466}
- component: {fileID: 8110114097174124860}
m_Layer: 5
m_Name: Description
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &1735022670865247660
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3804837773971309440}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 789625586990900961}
- {fileID: 2451722883515327516}
m_Father: {fileID: 332895709820465634}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 150, y: -50}
m_SizeDelta: {x: 300, y: 40}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &7312414472714823656
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3804837773971309440}
m_CullTransparentMesh: 1
--- !u!114 &5296572890931267466
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3804837773971309440}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.LayoutElement
m_IgnoreLayout: 0
m_MinWidth: 300
m_MinHeight: 40
m_PreferredWidth: -1
m_PreferredHeight: -1
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!114 &8110114097174124860
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3804837773971309440}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 3245ec927659c4140ac4f8d17403cc18, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.ContentSizeFitter
m_HorizontalFit: 2
m_VerticalFit: 0
--- !u!1 &6532964554051255018
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 789625586990900961}
- component: {fileID: 4476208540601885936}
- component: {fileID: 284395028850057396}
m_Layer: 5
m_Name: Description
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &789625586990900961
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6532964554051255018}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 1735022670865247660}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: -20, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &4476208540601885936
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6532964554051255018}
m_CullTransparentMesh: 1
--- !u!114 &284395028850057396
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6532964554051255018}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TextMeshProUGUI
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: Description
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: ac4df5038ff71a34a993c7c9fa7316fd, type: 2}
m_sharedMaterial: {fileID: 5688540820414838853, guid: ac4df5038ff71a34a993c7c9fa7316fd, type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4294967295
m_fontColor: {r: 1, g: 1, b: 1, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 24
m_fontSizeBase: 24
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 1
m_VerticalAlignment: 256
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 0
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 0
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: 0, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!1 &6789644190267702728
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 259397633913720669}
- component: {fileID: 5309614983325892601}
- component: {fileID: 1610999632492459883}
m_Layer: 5
m_Name: Separator
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &259397633913720669
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6789644190267702728}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 4204169304323323032}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0.5}
m_AnchorMax: {x: 1, y: 0.5}
m_AnchoredPosition: {x: 0, y: -15}
m_SizeDelta: {x: 0, y: 1}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &5309614983325892601
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6789644190267702728}
m_CullTransparentMesh: 1
--- !u!114 &1610999632492459883
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6789644190267702728}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
m_Material: {fileID: 0}
m_Color: {r: 0.5, g: 0.5, b: 0.5, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 0}
m_Type: 0
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: dc9a7ff16d23a2c41a0152ce105060be
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,20 @@
{
"name": "SLSUtilities.Narrative",
"rootNamespace": "",
"references": [
"GUID:43bb4e992d9b32b4bbb25402b41e80a0",
"GUID:34aa492b82754644eac2f903cd496268",
"GUID:6055be8ebefd69e48b49212b09b47b2f",
"GUID:75469ad4d38634e559750d17036d5f7c",
"GUID:cfcd2ce455f8d1944942cdd919ecaa60"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 22a86856172c06146a539eeb9c9c67f5
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

@@ -0,0 +1,26 @@
using Sirenix.OdinInspector;
using UnityEngine;
using Yarn.Unity;
namespace SLSUtilities.Narrative.UI
{
/// <summary>
/// 高级输入控制器,继承自官方 LineAdvancer。
/// 在保留所有原生输入处理逻辑的基础上,
/// 当关键词 Tooltip 窗口处于打开状态时,阻断本帧的台词推进输入。
/// </summary>
public class AdvancedLineAdvancer : LineAdvancer
{
protected override void RequestLineHurryUpInternal()
{
if (KeywordTooltipUI.IsBlockingDialogueInput) return;
base.RequestLineHurryUpInternal();
}
public override void RequestNextLine()
{
if (KeywordTooltipUI.IsBlockingDialogueInput) return;
base.RequestNextLine();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7c3b5f02f9e109140b36d9e3bad02271

View File

@@ -0,0 +1,305 @@
using System;
using System.Collections.Generic;
using Sirenix.OdinInspector;
using UnityEngine;
using UnityEngine.UI;
using Yarn.Unity;
using Yarn.Markup;
namespace SLSUtilities.Narrative.UI
{
/// <summary>
/// 高级台词展现层,继承自官方 LinePresenter。
/// 在保留所有原生功能打字机、淡入淡出、LineAdvancer 状态机)的基础上,
/// 扩展了角色立绘/头像切换、关键词高亮与悬停百科等功能。
/// </summary>
public class AdvancedLinePresenter : LinePresenter
{
[TitleGroup("立绘系统 (Portrait System)", Alignment = TitleAlignments.Centered)]
[BoxGroup("立绘系统 (Portrait System)/UI 引用")]
[Tooltip("用于显示角色立绘/头像的 Image 组件")]
[SerializeField] private Image portraitImage;
[BoxGroup("立绘系统 (Portrait System)/UI 引用")]
[Tooltip("立绘的容器节点。当没有立绘时整体隐藏,避免空白区域占位")]
[SerializeField] private GameObject portraitContainer;
[TitleGroup("关键词系统 (Keyword System)", Alignment = TitleAlignments.Centered)]
[BoxGroup("关键词系统 (Keyword System)/设置")]
[LabelText("启用关键词高亮")]
[Tooltip("是否在台词中自动识别并高亮关键词")]
[SerializeField] private bool enableKeywordHighlight = true;
[BoxGroup("关键词系统 (Keyword System)/设置")]
[ShowIf(nameof(enableKeywordHighlight))]
[LabelText("高亮颜色 (Highlight Color)")]
[Tooltip("关键词文本的高亮颜色")]
[SerializeField] private Color keywordHighlightColor = new Color(0.67f, 0.87f, 1f); // #AADDFF
// 缓存是否已构建的标记,避免每句台词重复构建
private bool _keywordCacheBuilt = false;
// 当前台词的 Markup 解析结果,保存到 PostProcessDisplayText 使用
private MarkupParseResult _currentLineMarkup;
/// <summary>最后一次说话的角色名称Yarn 脚本中的 CharacterName</summary>
public static string LastSpeakerName { get; set; }
/// <summary>当行内 Markup 标记请求播放动画时触发此事件</summary>
public static event Action<string, string> OnPlayAnimationRequested;
/// <summary>当行内 Markup 标记请求停止动画时触发此事件</summary>
public static event Action<string> OnStopAnimationRequested;
// ---------------------------------------------------------------
// 生命周期
// ---------------------------------------------------------------
public override YarnTask OnDialogueStartedAsync()
{
// 在对话开始时构建关键词缓存
if (enableKeywordHighlight && StorySystem.Database != null)
{
KeywordProcessor.BuildCache(StorySystem.Database.keywords);
_keywordCacheBuilt = true;
}
return base.OnDialogueStartedAsync();
}
public override YarnTask OnDialogueCompleteAsync()
{
_keywordCacheBuilt = false;
LastSpeakerName = null; // 对话结束时置空
return base.OnDialogueCompleteAsync();
}
// ---------------------------------------------------------------
// 台词处理
// ---------------------------------------------------------------
public override async YarnTask RunLineAsync(LocalizedLine line, LineCancellationToken token)
{
if (!string.IsNullOrEmpty(line.CharacterName))
{
LastSpeakerName = line.CharacterName; // 记录当前说话角色
}
// 确保打字机已通过 IAsyncTypewriter 装饰器包裹,防止其在开始打字时抹除关键词富文本标签。
// 基类 Awake 必然已实例化 Typewriter在此将其替换为我们的富文本打字机包装器。
if (Typewriter != null && !(Typewriter is KeywordTypewriterWrapper))
{
Typewriter = new KeywordTypewriterWrapper(Typewriter, markup => KeywordProcessor.ProcessWithMarkup(markup));
}
// 保存当前台词的 Markup供 PostProcessDisplayText 使用
_currentLineMarkup = line.TextWithoutCharacterName;
// 提取并解析 Yarn Markup 行内动作标记 (方案 B)
if (line.TextWithoutCharacterName.Attributes != null)
{
foreach (var attribute in line.TextWithoutCharacterName.Attributes)
{
if (attribute.Name == "anim" || attribute.Name == "play_animation")
{
if (attribute.Properties.TryGetValue(attribute.Name, out var animValue))
{
string animName = animValue.StringValue;
// 默认指向当前发言说话人,并解析为标准英文 ID
string targetNpc = ResolveStandardCharacterId(line.CharacterName);
// 同时也支持显式指定其他 NPC例如 [anim="PushButton" npc="SLS"/]
if (attribute.Properties.TryGetValue("npc", out var npcValue))
{
targetNpc = ResolveStandardCharacterId(npcValue.StringValue);
}
OnPlayAnimationRequested?.Invoke(animName, targetNpc);
}
}
else if (attribute.Name == "stop_anim" || attribute.Name == "stop_animation")
{
string targetNpc = ResolveStandardCharacterId(line.CharacterName);
if (attribute.Properties.TryGetValue("npc", out var npcValue))
{
targetNpc = ResolveStandardCharacterId(npcValue.StringValue);
}
OnStopAnimationRequested?.Invoke(targetNpc);
}
}
}
// 在台词展示之前更新立绘
UpdatePortrait(line);
// 调用父类处理所有其余逻辑(文本设置、打字机、淡入淡出、等待输入)
// 父类会在 Typewriter.PrepareForContent 之后调用 PostProcessDisplayText()
await base.RunLineAsync(line, token);
}
/// <summary>
/// 由父类 LinePresenter 在 Typewriter.PrepareForContent 之后调用。
/// 此时文本已完整设置到 TMP 组件上,但打字机尚未开始逐字展示。
/// 我们在此注入关键词的 link 标签。
/// </summary>
protected override void PostProcessDisplayText()
{
if (!enableKeywordHighlight || !_keywordCacheBuilt) return;
if (lineText == null) return;
// 使用基于 [kw] Markup 标签的处理,替代正则自动扫描
lineText.text = KeywordProcessor.ProcessWithMarkup(_currentLineMarkup);
}
// ---------------------------------------------------------------
// 立绘系统
// ---------------------------------------------------------------
private void UpdatePortrait(LocalizedLine line)
{
if (portraitImage == null) return;
string characterName = line.CharacterName;
if (string.IsNullOrWhiteSpace(characterName) || StorySystem.Database == null)
{
SetPortraitVisible(false);
return;
}
CharacterData charData = null;
foreach (var c in StorySystem.Database.characters)
{
if (c != null && (string.Equals(c.nameKey, characterName, StringComparison.OrdinalIgnoreCase) ||
c.alias.Contains(characterName)))
{
charData = c;
break;
}
}
if (charData == null)
{
SetPortraitVisible(false);
return;
}
YarnTagParser.Parse(line.Metadata, out var kvTags, out _);
kvTags.TryGetValue("mood", out string mood);
Sprite targetSprite = ResolvePortraitSprite(charData, mood);
if (targetSprite == null)
{
SetPortraitVisible(false);
return;
}
portraitImage.sprite = targetSprite;
SetPortraitVisible(true);
}
private static Sprite ResolvePortraitSprite(CharacterData charData, string mood)
{
if (!string.IsNullOrEmpty(mood) &&
charData.expressions != null &&
charData.expressions.TryGetValue(mood, out Sprite moodSprite) &&
moodSprite != null)
{
return moodSprite;
}
return charData.defaultPortrait;
}
private void SetPortraitVisible(bool visible)
{
if (portraitContainer != null)
portraitContainer.SetActive(visible);
else if (portraitImage != null)
portraitImage.enabled = visible;
}
/// <summary>
/// 将 Yarn 传回的本地化说话人名字(如 "引导者")反向解析为系统内部注册的标准英文 ID如 "Guide")。
/// </summary>
private string ResolveStandardCharacterId(string speakerName)
{
if (string.IsNullOrEmpty(speakerName) || StorySystem.Database == null)
return speakerName;
foreach (var c in StorySystem.Database.characters)
{
if (c == null) continue;
// 1. 若已经是标准英文名 (nameKey),直接返回
if (string.Equals(c.nameKey, speakerName, StringComparison.OrdinalIgnoreCase))
{
return c.nameKey;
}
// 2. 若匹配到显示名称 (displayName),则返回对应的标准英文 ID (nameKey)
if (c.alias != null && c.alias.Contains(speakerName))
{
return c.nameKey;
}
}
return speakerName; // 未匹配到则保留原样
}
}
/// <summary>
/// 关键词打字机包装器。
/// 解决官方打字机在 PrepareForContent 和 RunTypewriter 时,
/// 会强行用 plainText 覆盖 text 组件,导致我们注入的富文本高亮标签被抹除的问题。
/// </summary>
public class KeywordTypewriterWrapper : IAsyncTypewriter
{
private readonly IAsyncTypewriter _inner;
private readonly Func<MarkupParseResult, string> _processMarkupFunc;
public KeywordTypewriterWrapper(IAsyncTypewriter inner, Func<MarkupParseResult, string> processMarkupFunc)
{
_inner = inner;
_processMarkupFunc = processMarkupFunc;
}
public TMPro.TMP_Text? TextElement
{
get => _inner.TextElement;
set => _inner.TextElement = value;
}
public List<IActionMarkupHandler> ActionMarkupHandlers => _inner.ActionMarkupHandlers;
public void PrepareForContent(MarkupParseResult line)
{
_inner.PrepareForContent(line);
if (TextElement != null)
{
TextElement.text = _processMarkupFunc(line);
}
}
public async YarnTask RunTypewriter(MarkupParseResult line, System.Threading.CancellationToken cancellationToken)
{
var task = _inner.RunTypewriter(line, cancellationToken);
if (TextElement != null)
{
TextElement.text = _processMarkupFunc(line);
}
await task;
if (TextElement != null)
{
TextElement.text = _processMarkupFunc(line);
}
}
public void ContentWillDismiss() => _inner.ContentWillDismiss();
public void ContentDidDismiss() => _inner.ContentDidDismiss();
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1762f73b835dbd24f934d49bcb0c3c8f

View File

@@ -0,0 +1,128 @@
using UnityEngine;
using UnityEngine.EventSystems;
using Yarn.Unity;
using Yarn.Markup;
using TMPro;
namespace SLSUtilities.Narrative.UI
{
/// <summary>
/// 高级选项项,继承自官方 OptionItem。
/// 支持解析选项中的 #desc: 和 #fail: 元数据标签,
/// 通过 OptionTooltipUI 显示选项提示,
/// 以及使用 [kw] Yarn Markup 标签高亮关键词。
/// </summary>
public class AdvancedOptionItem : OptionItem
{
public string TooltipDesc { get; private set; }
public string TooltipFail { get; private set; }
public TMP_Text GetTextComponent() => text;
// 追踪当前是否是由鼠标指针触发的选中状态
// OnPointerEnter 在 OnSelect 之前同步设置此标记OnDeselect 清除
private bool _pointerEntered = false;
public override DialogueOption Option
{
get => base.Option;
set
{
// 调用基类 setter设置 _option、interactable 和 ApplyStyle
// 注意:基类也会设置 text.text我们稍后会覆盖它
base.Option = value;
// 解析 Tooltip 元数据标签 (#desc: / #fail:)
TooltipDesc = null;
TooltipFail = null;
if (value.Line.Metadata != null)
{
YarnTagParser.Parse(value.Line.Metadata, out var kvTags, out _);
if (kvTags.TryGetValue("desc", out string desc))
{
TooltipDesc = desc.Replace("_", " ");
}
if (kvTags.TryGetValue("fail", out string fail))
{
TooltipFail = fail.Replace("_", " ");
}
}
// 选项文本高亮处理:
// 仅通过 ProcessWithMarkup 处理手动标记的 [kw] 或 [kw id="..."] 标签。
// 不进行正则自动扫描(只高亮明确被 [kw] 标记的部分,与台词表现一致)。
if (text != null)
{
string processed = KeywordProcessor.ProcessWithMarkup(value.Line.TextWithoutCharacterName);
if (disabledStrikeThrough && !value.IsAvailable)
{
processed = $"<s>{processed}</s>";
}
text.text = processed;
}
}
}
public override void OnPointerEnter(PointerEventData eventData)
{
// 在调用 base.OnPointerEnter其中会同步调用 OnSelect之前
// 设置标记,使 OnSelect 能知道这是鼠标触发的
_pointerEntered = true;
base.OnPointerEnter(eventData);
}
public override void OnPointerExit(PointerEventData eventData)
{
base.OnPointerExit(eventData);
// 鼠标离开后清除,但不关闭 Tooltip由 OptionTooltipUI 的文本区域检测控制)
_pointerEntered = false;
}
public override void OnPointerClick(PointerEventData eventData)
{
// 只有当点击的是鼠标左键时,才允许选择并推进此选项,阻断鼠标右键的选择触发
if (eventData.button == PointerEventData.InputButton.Left)
{
base.OnPointerClick(eventData);
}
}
public override void OnSelect(BaseEventData eventData)
{
base.OnSelect(eventData);
// 将"是否为鼠标触发"传递给 OptionTooltipUI用于决定定位方式
OptionTooltipUI.Instance?.OnOptionSelected(this, _pointerEntered);
}
public override void OnDeselect(BaseEventData eventData)
{
base.OnDeselect(eventData);
_pointerEntered = false;
OptionTooltipUI.Instance?.OnOptionDeselected(this);
}
protected override void OnEnable()
{
base.OnEnable();
if (KeywordTooltipUI.Instance != null && text != null)
{
KeywordTooltipUI.Instance.RegisterExternalText(text);
}
}
protected override void OnDisable()
{
base.OnDisable();
if (KeywordTooltipUI.Instance != null && text != null)
{
KeywordTooltipUI.Instance.UnregisterExternalText(text);
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3965a9528049b914baeeff5e76f39162

View File

@@ -0,0 +1,74 @@
using UnityEngine;
using Yarn.Unity;
using Sirenix.OdinInspector;
namespace SLSUtilities.Narrative.UI
{
/// <summary>
/// 高级选项展现层,继承自官方 OptionsPresenter。
/// </summary>
public class AdvancedOptionsPresenter : OptionsPresenter
{
[TitleGroup("Advanced Settings", Alignment = TitleAlignments.Centered)]
[BoxGroup("Advanced Settings/Prefabs")]
[Required("需要指定 AdvancedOptionItem 预制体")]
[SerializeField] private AdvancedOptionItem advancedOptionViewPrefab;
public override YarnTask OnDialogueStartedAsync()
{
// 建立关键词缓存,保证 ProcessWithMarkup 能正确查找关键词数据
// (不依赖 AdvancedLinePresenter 是否已经运行)
if (StorySystem.Database != null)
KeywordProcessor.BuildCache(StorySystem.Database.keywords);
return base.OnDialogueStartedAsync();
}
public override YarnTask<DialogueOption?> RunOptionsAsync(DialogueOption[] dialogueOptions, LineCancellationToken cancellationToken)
{
// 过滤掉不可用且带有 "hide" 或 "#hide" 标签的选项
var filteredOptions = new System.Collections.Generic.List<DialogueOption>();
foreach (var option in dialogueOptions)
{
bool shouldHide = false;
if (!option.IsAvailable && option.Line != null && option.Line.Metadata != null)
{
foreach (var tag in option.Line.Metadata)
{
if (tag.Equals("hide", System.StringComparison.OrdinalIgnoreCase) ||
tag.Equals("#hide", System.StringComparison.OrdinalIgnoreCase))
{
shouldHide = true;
break;
}
}
}
if (!shouldHide)
{
filteredOptions.Add(option);
}
}
return base.RunOptionsAsync(filteredOptions.ToArray(), cancellationToken);
}
protected override OptionItem CreateNewOptionView()
{
var targetTransform = canvasGroup != null ? canvasGroup.transform : this.transform;
var optionView = Instantiate(advancedOptionViewPrefab, targetTransform, false);
if (optionView == null)
{
Debug.LogError("Failed to instantiate advancedOptionViewPrefab.");
return null;
}
optionView.transform.SetAsLastSibling();
optionView.gameObject.SetActive(false);
return optionView;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7e64bee4ae3276841b0949789632d0b4

View File

@@ -0,0 +1,263 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using UnityEngine;
using Yarn.Markup;
namespace SLSUtilities.Narrative.UI
{
/// <summary>
/// 关键词文本处理器。
/// 扫描台词文本,将已注册的关键词包裹为 TMP <link> 标签,
/// 使其可被悬停检测和高亮显示。
/// </summary>
public static class KeywordProcessor
{
/// <summary>
/// 关键词的 link ID 前缀,用于与其他 link 类型区分。
/// 例如:<link="kw:灵能者">
/// </summary>
public const string LinkPrefix = "kw:";
// 已编译的关键词匹配正则(所有触发词按长度降序排列)
private static Regex _keywordPattern;
// 触发词 → KeywordData 的映射表(大小写不敏感)
private static Dictionary<string, KeywordData> _triggerLookup;
// 主关键词 → KeywordData 的映射表(用于通过 link ID 反查)
private static Dictionary<string, KeywordData> _primaryLookup;
// 用于识别 RichText 标签的正则,避免在标签内部匹配关键词
private static readonly Regex RichTextTagPattern =
new Regex(@"<[^>]+>", RegexOptions.Compiled);
/// <summary>
/// 从数据库构建关键词缓存。
/// 应在对话开始时调用一次,或在关键词列表变更后重新调用。
/// </summary>
public static void BuildCache(List<KeywordData> keywords)
{
_triggerLookup = new Dictionary<string, KeywordData>(System.StringComparer.OrdinalIgnoreCase);
_primaryLookup = new Dictionary<string, KeywordData>(System.StringComparer.OrdinalIgnoreCase);
if (keywords == null || keywords.Count == 0)
{
_keywordPattern = null;
return;
}
var allTriggers = new List<string>();
foreach (var kw in keywords)
{
if (kw == null || string.IsNullOrWhiteSpace(kw.keyword)) continue;
// 注册主关键词到 primaryLookup
if (!_primaryLookup.ContainsKey(kw.keyword))
_primaryLookup[kw.keyword] = kw;
// 注册所有触发词(主关键词 + 别名)到 triggerLookup
foreach (var trigger in kw.GetAllTriggerWords())
{
if (!string.IsNullOrWhiteSpace(trigger) && !_triggerLookup.ContainsKey(trigger))
{
_triggerLookup[trigger] = kw;
allTriggers.Add(trigger);
}
}
}
if (allTriggers.Count == 0)
{
_keywordPattern = null;
return;
}
// 按长度降序排列,确保"灵能者协会"优先于"灵能者"匹配
allTriggers.Sort((a, b) => b.Length.CompareTo(a.Length));
// 构建正则:将所有触发词用 | 连接
var escapedTriggers = allTriggers.Select(Regex.Escape);
string pattern = string.Join("|", escapedTriggers);
_keywordPattern = new Regex(pattern, RegexOptions.IgnoreCase | RegexOptions.Compiled);
}
/// <summary>
/// 处理台词文本:扫描并将匹配的关键词包裹为带高亮样式的 TMP link 标签。
/// 会自动跳过已有的 RichText 标签内部,避免破坏现有排版。
/// </summary>
/// <param name="rawText">原始台词文本(可能已包含 RichText 标签)</param>
/// <param name="highlightColor">关键词高亮颜色(十六进制,如 "#AADDFF"</param>
/// <returns>处理后的文本</returns>
public static string Process(string rawText, string highlightColor = "#AADDFF")
{
if (_keywordPattern == null || string.IsNullOrEmpty(rawText))
return rawText;
// 将文本按"RichText 标签"和"纯文本"交替切分,
// 只对纯文本段进行关键词替换,标签段原样保留。
var result = new StringBuilder(rawText.Length * 2);
int lastIndex = 0;
foreach (Match tagMatch in RichTextTagPattern.Matches(rawText))
{
// 处理标签前的纯文本段
if (tagMatch.Index > lastIndex)
{
string segment = rawText.Substring(lastIndex, tagMatch.Index - lastIndex);
result.Append(ReplaceKeywordsInSegment(segment, highlightColor));
}
// 标签本身原样追加
result.Append(tagMatch.Value);
lastIndex = tagMatch.Index + tagMatch.Length;
}
// 处理最后一个标签之后的剩余纯文本
if (lastIndex < rawText.Length)
{
string remaining = rawText.Substring(lastIndex);
result.Append(ReplaceKeywordsInSegment(remaining, highlightColor));
}
return result.ToString();
}
/// <summary>
/// 基于 Yarn Markup 属性处理台词/选项文本:
/// 将 [kw] 和 [kw id="主关键词"] 标签转换为 TMP link/style 标签。
///
/// Yarn 标签语法:
/// [kw]迎雾森林[/kw] —— 关键词 = 标签内的文本
/// [kw id="迎雾森林"]林地[/kw] —— 关键词 = “迎雾森林”,显示文本 = “林地”
/// </summary>
/// <param name="markup">来自 Yarn 的 MarkupParseResultLocalizedLine.TextWithoutCharacterName 等)</param>
/// <returns>包含 TMP 忌工标签的处理后字符串</returns>
public static string ProcessWithMarkup(MarkupParseResult markup)
{
string plainText = markup.Text;
if (string.IsNullOrEmpty(plainText)) return plainText;
// 收集所有 "kw" 属性
var kwAttributes = new List<MarkupAttribute>();
foreach (var attr in markup.Attributes)
{
if (attr.Name == "kw")
kwAttributes.Add(attr);
}
// 没有关键词标签,直接返回原始文本
if (kwAttributes.Count == 0) return plainText;
// 按位置降序排列,从字符串末尾开始插入,避免早期插入造成索引偏移
kwAttributes.Sort((a, b) => b.Position.CompareTo(a.Position));
var sb = new StringBuilder(plainText);
foreach (var attr in kwAttributes)
{
// 确定关键词:优先使用 id 属性,否则取标签覆盖的文本
string keyword;
if (attr.Properties.TryGetValue("id", out var idProp))
{
keyword = idProp.StringValue;
}
else
{
keyword = plainText.Substring(attr.Position, attr.Length);
}
// 在数据库中查找该关键词,找不到则跳过(不插入任何标签)
if (FindByPrimaryKeyword(keyword) == null) continue;
// 先插入闭合标签(索引较大),再插入开放标签
sb.Insert(attr.Position + attr.Length, "</link></style>");
sb.Insert(attr.Position, $"<style=\"kw\"><link=\"{LinkPrefix}{keyword}\">");
}
return sb.ToString();
}
/// <summary>
/// 处理关键词的解释文本(用于嵌套 Tooltip
/// 与 Process 相同,但会排除自身关键词,避免自引用循环。
/// </summary>
public static string ProcessDescription(string description, string excludeKeyword,
string highlightColor = "#AADDFF")
{
if (_keywordPattern == null || string.IsNullOrEmpty(description))
return description;
var result = new StringBuilder(description.Length * 2);
int lastIndex = 0;
foreach (Match tagMatch in RichTextTagPattern.Matches(description))
{
if (tagMatch.Index > lastIndex)
{
string segment = description.Substring(lastIndex, tagMatch.Index - lastIndex);
result.Append(ReplaceKeywordsInSegment(segment, highlightColor, excludeKeyword));
}
result.Append(tagMatch.Value);
lastIndex = tagMatch.Index + tagMatch.Length;
}
if (lastIndex < description.Length)
{
string remaining = description.Substring(lastIndex);
result.Append(ReplaceKeywordsInSegment(remaining, highlightColor, excludeKeyword));
}
return result.ToString();
}
/// <summary>
/// 通过 link ID去掉 "kw:" 前缀后的主关键词)反查对应的 KeywordData。
/// </summary>
public static KeywordData FindByPrimaryKeyword(string primaryKeyword)
{
if (_primaryLookup == null || string.IsNullOrEmpty(primaryKeyword))
return null;
_primaryLookup.TryGetValue(primaryKeyword, out var result);
return result;
}
/// <summary>
/// 从 TMP link ID 字符串中提取主关键词。
/// 例如输入 "kw:灵能者",返回 "灵能者"。
/// 如果不是关键词类型的 link返回 null。
/// </summary>
public static string ExtractKeywordFromLinkId(string linkId)
{
if (string.IsNullOrEmpty(linkId) || !linkId.StartsWith(LinkPrefix))
return null;
return linkId.Substring(LinkPrefix.Length);
}
// -------------------------------------------------------------------
private static string ReplaceKeywordsInSegment(string segment, string color,
string excludeKeyword = null)
{
return _keywordPattern.Replace(segment, match =>
{
if (!_triggerLookup.TryGetValue(match.Value, out var kwData))
return match.Value;
// 排除自身关键词(用于嵌套 Tooltip 防止自引用)
if (excludeKeyword != null &&
string.Equals(kwData.keyword, excludeKeyword, System.StringComparison.OrdinalIgnoreCase))
return match.Value;
// 包裹为 TMP link 标签
// link ID 格式: "kw:主关键词"
return $"<style=\"kw\"><link=\"{LinkPrefix}{kwData.keyword}\">{match.Value}</link></style>";
});
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 045da86a92e1064419ec0949cf6a3c51

View File

@@ -0,0 +1,337 @@
using System.Collections;
using System.Collections.Generic;
using Sirenix.OdinInspector;
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
namespace SLSUtilities.Narrative.UI
{
/// <summary>
/// 关键词浮动窗口管理器。
/// 检测鼠标在 TMP 文本上悬停的 link 标签,弹出关键词解释窗口。
/// 支持嵌套窗口、右键固定、点击外部关闭,以及左键关闭时阻断台词推进。
///
/// 面板的实际内容显示、定位和固定状态由 <see cref="TooltipPanel"/> 组件管理,
/// 本类仅负责悬停检测、生命周期编排和输入分发。
/// </summary>
public class KeywordTooltipUI : MonoBehaviour
{
// ---------------------------------------------------------------
// 静态属性:供 LineAdvancer 查询是否需要阻断本帧输入
// ---------------------------------------------------------------
/// <summary>
/// 当任意 Tooltip 窗口处于打开状态时为 true。
/// LineAdvancer 应在处理台词推进前检查此值。
/// </summary>
public static bool IsBlockingDialogueInput { get; private set; }
public static KeywordTooltipUI Instance { get; private set; }
// ---------------------------------------------------------------
// Inspector 配置
// ---------------------------------------------------------------
[TitleGroup("核心引用 (Core References)", Alignment = TitleAlignments.Centered)]
[BoxGroup("核心引用 (Core References)/UI")]
[Required("需要指定 Tooltip 面板的 Prefab必须挂载 TooltipPanel 组件)")]
[SerializeField] private GameObject tooltipPanelPrefab;
[BoxGroup("核心引用 (Core References)/UI")]
[Required("Tooltip 生成的父级容器RectTransform")]
[SerializeField] private RectTransform tooltipContainer;
[BoxGroup("核心引用 (Core References)/UI")]
[Tooltip("主台词文本组件,用于检测鼠标悬停的关键词链接")]
[SerializeField] private TMP_Text mainLineText;
[BoxGroup("核心引用 (Core References)/UI")]
[Tooltip("渲染 Canvas 的摄像机。Screen Space Overlay 模式下留空")]
[SerializeField] private Camera uiCamera;
[TitleGroup("行为设置 (Behavior Settings)", Alignment = TitleAlignments.Centered)]
[BoxGroup("行为设置 (Behavior Settings)/定位")]
[Tooltip("Tooltip 左下角相对于鼠标的屏幕像素偏移量")]
[SerializeField] private Vector2 tooltipOffset = new Vector2(12f, 12f);
// ---------------------------------------------------------------
// 内部状态
// ---------------------------------------------------------------
// 所有当前打开的 Tooltip 面板(包含固定的和悬停的)
private readonly List<TooltipPanel> _openPanels = new List<TooltipPanel>();
// 当前唯一的悬停 Tooltip未固定跟随鼠标
private TooltipPanel _hoverPanel;
// 上一帧检测到的悬停关键词
private string _lastHoveredKeyword;
// 供外部(如 OptionTooltipUI注册的额外检测文本
private readonly List<TMP_Text> _externalTexts = new List<TMP_Text>();
/// <summary>
/// 当前是否有未固定的悬停面板
/// </summary>
public bool HasHoverPanel => _hoverPanel != null;
// ---------------------------------------------------------------
// Unity 生命周期
// ---------------------------------------------------------------
private void Awake()
{
Instance = this;
}
private void OnDisable()
{
CloseAllTooltips();
}
private void Update()
{
HandleHoverDetection();
HandleClickInput();
}
// ---------------------------------------------------------------
// 悬停检测
// ---------------------------------------------------------------
private void HandleHoverDetection()
{
Vector2 mousePos = Mouse.current.position.ReadValue();
// 检测当前鼠标命中的关键词链接
string hoveredKeyword = DetectHoveredKeyword(mousePos);
// 如果鼠标没命中链接,但在当前 Hover 面板内,保持悬停状态不变
bool mouseInsideHoverPanel = _hoverPanel != null &&
_hoverPanel.ContainsScreenPoint(mousePos, uiCamera);
if (mouseInsideHoverPanel && hoveredKeyword == null)
{
// 鼠标从链接移到了 Tooltip 面板 → 保持显示,不移动位置
return;
}
// 悬停目标变化 → 刷新 Hover Tooltip
if (hoveredKeyword != _lastHoveredKeyword)
{
_lastHoveredKeyword = hoveredKeyword;
CloseHoverTooltip();
if (!string.IsNullOrEmpty(hoveredKeyword))
{
// 如果该关键词已经有固定窗口存在 → 不创建新的 Hover
if (!HasPinnedPanelForKeyword(hoveredKeyword))
{
var kwData = KeywordProcessor.FindByPrimaryKeyword(hoveredKeyword);
if (kwData != null)
_hoverPanel = SpawnPanel(kwData, mousePos, pinned: false);
}
}
}
// 跟随鼠标更新位置(仅对 Hover 面板)
if (_hoverPanel != null && !_hoverPanel.IsPinned)
_hoverPanel.PositionAtScreenPoint(mousePos, tooltipOffset);
}
private string DetectHoveredKeyword(Vector2 mousePos)
{
// 先检测主台词文本
string kw = DetectLinkAt(mainLineText, mousePos);
if (kw != null) return kw;
// 再检测所有已打开面板内的描述文本(支持嵌套)
foreach (var panel in _openPanels)
{
kw = DetectLinkAt(panel.DescriptionText, mousePos);
if (kw != null) return kw;
}
// 最后检测外部注册的文本(如选项文本)
foreach (var extText in _externalTexts)
{
kw = DetectLinkAt(extText, mousePos);
if (kw != null) return kw;
}
return null;
}
// ---------------------------------------------------------------
// 点击输入处理
// ---------------------------------------------------------------
private void HandleClickInput()
{
bool leftClick = Mouse.current.leftButton.wasPressedThisFrame;
bool rightClick = Mouse.current.rightButton.wasPressedThisFrame;
if (!leftClick && !rightClick) return;
if (_openPanels.Count == 0) return;
Vector2 mousePos = Mouse.current.position.ReadValue();
// 检测是否右键点击在关键词链接上 → 固定 Hover 面板
if (rightClick)
{
string clickedKeyword = DetectHoveredKeyword(mousePos);
if (!string.IsNullOrEmpty(clickedKeyword) &&
_hoverPanel != null &&
_hoverPanel.Keyword == clickedKeyword)
{
PinHoverPanel();
return;
}
}
// 检测是否点击在任意 Tooltip 面板内部 → 如果是则不处理
if (IsMouseInsideAnyPanel(mousePos)) return;
// 点击在所有 Tooltip 外部 → 关闭所有 Tooltip
CloseAllTooltips();
// 左键关闭时,本帧阻断台词推进(下一帧自动解除)
if (leftClick)
{
IsBlockingDialogueInput = true;
StartCoroutine(UnblockNextFrame());
}
}
// ---------------------------------------------------------------
// 面板生命周期
// ---------------------------------------------------------------
private TooltipPanel SpawnPanel(KeywordData data, Vector2 screenPos, bool pinned)
{
if (tooltipPanelPrefab == null || tooltipContainer == null) return null;
var panelGO = Instantiate(tooltipPanelPrefab, tooltipContainer);
var panel = panelGO.GetComponent<TooltipPanel>();
if (panel == null)
{
Debug.LogError(
$"[KeywordTooltipUI] Tooltip Prefab 上缺少 TooltipPanel 组件!" +
$"请确保 Prefab '{tooltipPanelPrefab.name}' 挂载了 TooltipPanel 脚本。",
tooltipPanelPrefab);
Destroy(panelGO);
return null;
}
panel.Initialize(data, pinned);
panel.PositionAtScreenPoint(screenPos, tooltipOffset);
_openPanels.Add(panel);
IsBlockingDialogueInput = true;
return panel;
}
private void PinHoverPanel()
{
if (_hoverPanel == null) return;
_hoverPanel.Pin();
_hoverPanel = null;
_lastHoveredKeyword = null;
}
private void CloseHoverTooltip()
{
if (_hoverPanel == null) return;
_openPanels.Remove(_hoverPanel);
if (_hoverPanel.gameObject != null)
Destroy(_hoverPanel.gameObject);
_hoverPanel = null;
if (_openPanels.Count == 0)
IsBlockingDialogueInput = false;
}
private void CloseAllTooltips()
{
foreach (var panel in _openPanels)
{
if (panel != null && panel.gameObject != null)
Destroy(panel.gameObject);
}
_openPanels.Clear();
_hoverPanel = null;
_lastHoveredKeyword = null;
IsBlockingDialogueInput = false;
}
// ---------------------------------------------------------------
// 查询方法
// ---------------------------------------------------------------
/// <summary>
/// 检查指定关键词是否已有固定的面板存在。
/// 用于避免为同一个关键词生成重复的 Hover 面板。
/// </summary>
private bool HasPinnedPanelForKeyword(string keyword)
{
foreach (var panel in _openPanels)
{
if (panel.IsPinned && panel.Keyword == keyword)
return true;
}
return false;
}
// ---------------------------------------------------------------
// 工具方法
// ---------------------------------------------------------------
private string DetectLinkAt(TMP_Text tmpText, Vector2 screenPos)
{
if (tmpText == null) return null;
int linkIndex = TMP_TextUtilities.FindIntersectingLink(tmpText, screenPos, uiCamera);
if (linkIndex < 0) return null;
string linkId = tmpText.textInfo.linkInfo[linkIndex].GetLinkID();
return KeywordProcessor.ExtractKeywordFromLinkId(linkId);
}
private bool IsMouseInsideAnyPanel(Vector2 screenPos)
{
foreach (var panel in _openPanels)
{
if (panel != null && panel.ContainsScreenPoint(screenPos, uiCamera))
return true;
}
return false;
}
private IEnumerator UnblockNextFrame()
{
yield return null;
IsBlockingDialogueInput = false;
}
public void RegisterExternalText(TMP_Text text)
{
if (text != null && !_externalTexts.Contains(text))
_externalTexts.Add(text);
}
public void UnregisterExternalText(TMP_Text text)
{
if (text != null)
_externalTexts.Remove(text);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 78e77e9842502d948928cf5cb7c814d7

View File

@@ -0,0 +1,330 @@
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
using Sirenix.OdinInspector;
namespace SLSUtilities.Narrative.UI
{
/// <summary>
/// 选项悬停提示面板管理器。
/// - 鼠标模式:仅在鼠标悬停于选项"文本区域"(非空白行区域)时显示,跟随鼠标移动。
/// - 键盘模式:在选项文本的右上角处固定显示。
/// - 支持右键固定和点击外部关闭。
/// </summary>
public class OptionTooltipUI : MonoBehaviour
{
public static OptionTooltipUI Instance { get; private set; }
[TitleGroup("核心引用 (Core References)", Alignment = TitleAlignments.Centered)]
[BoxGroup("核心引用 (Core References)/UI")]
[Required("需要指定 Tooltip 面板的 Prefab必须挂载 TooltipPanel 组件)")]
[SerializeField] private GameObject tooltipPanelPrefab;
[BoxGroup("核心引用 (Core References)/UI")]
[Required("Tooltip 生成的父级容器RectTransform")]
[SerializeField] private RectTransform tooltipContainer;
[BoxGroup("核心引用 (Core References)/UI")]
[Tooltip("渲染 Canvas 的摄像机。Screen Space Overlay 模式下留空")]
[SerializeField] private Camera uiCamera;
[TitleGroup("行为设置 (Behavior Settings)", Alignment = TitleAlignments.Centered)]
[BoxGroup("行为设置 (Behavior Settings)/定位")]
[Tooltip("Tooltip 左下角相对于鼠标(或键盘时文本右上角)的像素偏移量")]
[SerializeField] private Vector2 tooltipOffset = new Vector2(12f, 12f);
[BoxGroup("行为设置 (Behavior Settings)/定位")]
[Tooltip("鼠标检测的边缘容差像素数。\n较大值可避免中文全角标点符号边缘闪烁较小值则更精确地限制在文字内。")]
[Range(0f, 20f)]
[SerializeField] private float textBoundsTolerance = 6f;
private TooltipPanel _hoverPanel;
private AdvancedOptionItem _hoverItem;
// 当前悬停是否由鼠标触发false = 键盘触发)
private bool _isMouseSelection;
// 已固定的选项 Tooltip
private readonly List<TooltipPanel> _pinnedPanels = new List<TooltipPanel>();
private void Awake()
{
Instance = this;
}
private void OnDisable()
{
CloseHoverPanel();
CloseAllPinnedPanels();
}
private void Update()
{
HandleHoverPanelVisibility();
HandleClickInput();
}
// ---------------------------------------------------------------
// 公开接口(由 AdvancedOptionItem 调用)
// ---------------------------------------------------------------
public void OnOptionSelected(AdvancedOptionItem item, bool isMouseTriggered)
{
_hoverItem = item;
_isMouseSelection = isMouseTriggered;
string textToShow = item.Option.IsAvailable ? item.TooltipDesc : item.TooltipFail;
if (string.IsNullOrWhiteSpace(textToShow))
{
CloseHoverPanel();
return;
}
if (HasPinnedPanelForOption(textToShow))
{
CloseHoverPanel();
return;
}
CloseHoverPanel();
_hoverPanel = SpawnPanel(textToShow);
if (_isMouseSelection)
{
// 鼠标模式:初始位置对齐鼠标,后续每帧跟随
Vector2 mousePos = Mouse.current.position.ReadValue();
_hoverPanel.PositionAtScreenPoint(mousePos, tooltipOffset);
}
else
{
// 键盘模式:定位在文本右上角处
PositionPanelAtTextTopRight(_hoverPanel, item.GetTextComponent());
}
}
public void OnOptionDeselected(AdvancedOptionItem item)
{
if (_hoverItem == item)
CloseHoverPanel();
}
// ---------------------------------------------------------------
// 每帧更新(悬停面板可见性与定位)
// ---------------------------------------------------------------
private void HandleHoverPanelVisibility()
{
if (_hoverPanel == null) return;
// 当选项文本中出现了关键词且玩家正在选中关键词时,隐去未固定的选项 Tooltip
bool isHoveringKeyword = KeywordTooltipUI.Instance != null && KeywordTooltipUI.Instance.HasHoverPanel;
if (isHoveringKeyword)
{
_hoverPanel.gameObject.SetActive(false);
return;
}
if (_isMouseSelection)
{
Vector2 mousePos = Mouse.current.position.ReadValue();
// 使用 textBounds 检测文本渲染边界,避免全角标点符号字形间隙造成闪烁
var textComp = _hoverItem?.GetTextComponent();
bool mouseOverText = IsMouseOverTextArea(textComp, mousePos);
if (mouseOverText)
{
_hoverPanel.gameObject.SetActive(true);
// 每帧跟随鼠标
_hoverPanel.PositionAtScreenPoint(mousePos, tooltipOffset);
}
else
{
_hoverPanel.gameObject.SetActive(false);
}
}
else
{
// 键盘模式:始终显示,位置固定在文本右上角(无需每帧更新)
_hoverPanel.gameObject.SetActive(true);
}
}
// ---------------------------------------------------------------
// 点击输入处理
// ---------------------------------------------------------------
private void HandleClickInput()
{
bool leftClick = Mouse.current.leftButton.wasPressedThisFrame;
bool rightClick = Mouse.current.rightButton.wasPressedThisFrame;
if (!leftClick && !rightClick) return;
Vector2 mousePos = Mouse.current.position.ReadValue();
// 右键固定:当 hover panel 可见时,右键单击在选项区域内将其固定
if (rightClick && _hoverPanel != null && _hoverPanel.gameObject.activeSelf)
{
bool isHoveringKeyword = KeywordTooltipUI.Instance != null && KeywordTooltipUI.Instance.HasHoverPanel;
if (!isHoveringKeyword)
{
// 使用与悬停检测相同的 textBounds 方式
var textComp = _hoverItem?.GetTextComponent();
bool clickOverText = IsMouseOverTextArea(textComp, mousePos);
bool clickOverPanel = _hoverPanel.ContainsScreenPoint(mousePos, uiCamera);
if (clickOverText || clickOverPanel)
{
PinHoverPanel();
return;
}
}
}
// 点击外部关闭所有已固定的选项 Tooltip
if (_pinnedPanels.Count > 0)
{
bool clickedInside = false;
foreach (var panel in _pinnedPanels)
{
if (panel.ContainsScreenPoint(mousePos, uiCamera))
{
clickedInside = true;
break;
}
}
if (!clickedInside && _hoverPanel != null && _hoverPanel.ContainsScreenPoint(mousePos, uiCamera))
clickedInside = true;
if (!clickedInside)
CloseAllPinnedPanels();
}
}
// ---------------------------------------------------------------
// 面板生命周期
// ---------------------------------------------------------------
private void PinHoverPanel()
{
if (_hoverPanel == null) return;
_hoverPanel.Pin();
_pinnedPanels.Add(_hoverPanel);
_hoverPanel = null;
}
private TooltipPanel SpawnPanel(string description)
{
if (tooltipPanelPrefab == null || tooltipContainer == null) return null;
var panelGO = Instantiate(tooltipPanelPrefab, tooltipContainer);
var panel = panelGO.GetComponent<TooltipPanel>();
// 创建临时 KeywordData内容为选项说明无标题
var data = ScriptableObject.CreateInstance<KeywordData>();
data.keyword = string.Empty;
// 选项说明中也支持嵌套关键词:由 Initialize 内的 ProcessDescription 自动处理
data.description = description;
panel.Initialize(data, false);
Destroy(data);
return panel;
}
// ---------------------------------------------------------------
// 定位工具
// ---------------------------------------------------------------
/// <summary>
/// 将面板定位在 TMP 文本组件的右上角处(用于键盘模式)。
/// </summary>
private void PositionPanelAtTextTopRight(TooltipPanel panel, TMP_Text textComp)
{
if (panel == null || textComp == null) return;
Vector3[] corners = new Vector3[4];
textComp.rectTransform.GetWorldCorners(corners);
// corners 顺序0=BL, 1=TL, 2=TR, 3=BR屏幕坐标Overlay模式
// 对于非 Overlay 模式,使用 WorldToScreenPoint 转换
Vector2 screenPos = uiCamera != null
? RectTransformUtility.WorldToScreenPoint(uiCamera, corners[2])
: new Vector2(corners[2].x, corners[2].y);
panel.PositionAtScreenPoint(screenPos, tooltipOffset);
}
// ---------------------------------------------------------------
// 文本区域检测工具
// ---------------------------------------------------------------
/// <summary>
/// 检测鼠标屏幕坐标是否处于 TMP 文本的实际渲染边界矩形内。
/// 使用 textBounds字形渲染包围盒而非 FindIntersectingCharacter
/// 可避免中文全角标点符号字形内空白区域导致的闪烁问题。
/// </summary>
private bool IsMouseOverTextArea(TMP_Text textComp, Vector2 screenMousePos)
{
if (textComp == null) return false;
// 确保 TMPro 网格在当下完成同步刷新,以获得 100% 准确的渲染包围盒,彻底阻断首帧零包围盒渲染计算闪烁
textComp.ForceMeshUpdate();
// 将屏幕坐标转换为 TMP RectTransform 的局部坐标
if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(
textComp.rectTransform, screenMousePos, uiCamera, out Vector2 localPoint))
return false;
// textBounds 是 TMP 实际渲染内容的包围盒(局部坐标),
// 比 RectTransform 本身更精确,且不受字符个体差异影响
Bounds bounds = textComp.textBounds;
// 加入可配置容差,避免全角标点字形边缘闪烁
return localPoint.x >= bounds.min.x - textBoundsTolerance
&& localPoint.x <= bounds.max.x + textBoundsTolerance
&& localPoint.y >= bounds.min.y - textBoundsTolerance
&& localPoint.y <= bounds.max.y + textBoundsTolerance;
}
// ---------------------------------------------------------------
// 查询工具
// ---------------------------------------------------------------
private bool HasPinnedPanelForOption(string description)
{
// 简单比较原始描述文本(未处理),避免二次处理比较问题
foreach (var panel in _pinnedPanels)
{
if (panel != null && panel.DescriptionText != null &&
panel.DescriptionText.text.Contains(description.Substring(0, Mathf.Min(description.Length, 10))))
{
return true;
}
}
return false;
}
private void CloseHoverPanel()
{
if (_hoverPanel != null && _hoverPanel.gameObject != null)
Destroy(_hoverPanel.gameObject);
_hoverPanel = null;
}
private void CloseAllPinnedPanels()
{
foreach (var panel in _pinnedPanels)
{
if (panel != null && panel.gameObject != null)
Destroy(panel.gameObject);
}
_pinnedPanels.Clear();
}
}
}

View File

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

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2aa296e25917c8b468ed9c19f3c90b38
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,269 @@
using Sirenix.OdinInspector;
using TMPro;
using UnityEngine;
using UnityEngine.Serialization;
using UnityEngine.UI;
namespace SLSUtilities.Narrative.UI
{
/// <summary>
/// 关键词浮动面板组件。
/// 挂载在 Tooltip Prefab 根节点上,负责管理单个面板的内容显示、
/// 固定状态及屏幕定位。
/// </summary>
public class TooltipPanel : MonoBehaviour
{
// ---------------------------------------------------------------
// Inspector 配置Prefab 内拖拽赋值)
// ---------------------------------------------------------------
[TitleGroup("面板引用 (Panel References)", Alignment = TitleAlignments.Centered)]
[BoxGroup("面板引用 (Panel References)/标题行")]
[LabelText("标题栏容器")]
[SerializeField] private RectTransform titleBarContainer;
[BoxGroup("面板引用 (Panel References)/标题行")]
[LabelText("标题文本")]
[SerializeField] private TMP_Text titleText;
[BoxGroup("面板引用 (Panel References)/标题行")]
[LabelText("关键词图标")]
[SerializeField] private Image iconImage;
[FormerlySerializedAs("pinIndicator")]
[BoxGroup("面板引用 (Panel References)/标题行")]
[LabelText("固定指示器")]
[SerializeField] private GameObject titlePin;
[BoxGroup("面板引用 (Panel References)/描述行")]
[LabelText("描述栏容器")]
[SerializeField] private RectTransform descriptionContainer;
[BoxGroup("面板引用 (Panel References)/描述行")]
[LabelText("描述文本")]
[SerializeField] private TMP_Text descriptionText;
[BoxGroup("面板引用 (Panel References)/描述行")]
[LabelText("固定指示器")]
[SerializeField] private GameObject descriptionPin;
// ---------------------------------------------------------------
// 公开属性
// ---------------------------------------------------------------
/// <summary>
/// 此面板对应的主关键词。
/// </summary>
public string Keyword { get; private set; }
/// <summary>
/// 该面板是否已被固定(右键固定后不再跟随鼠标,且不会因移开鼠标而关闭)。
/// </summary>
public bool IsPinned { get; private set; }
/// <summary>
/// 面板的 RectTransform 引用,供外部定位和碰撞检测。
/// </summary>
public RectTransform Rect { get; private set; }
/// <summary>
/// 面板内的描述文本组件引用,供外部检测嵌套链接。
/// </summary>
public TMP_Text DescriptionText => descriptionText;
// ---------------------------------------------------------------
// 生命周期
// ---------------------------------------------------------------
private void Awake()
{
Rect = GetComponent<RectTransform>();
}
// ---------------------------------------------------------------
// 初始化
// ---------------------------------------------------------------
/// <summary>
/// 初始化面板内容。由 KeywordTooltipUI 在实例化后调用。
/// </summary>
/// <param name="data">关键词数据</param>
/// <param name="pinned">是否初始即为固定状态</param>
public void Initialize(KeywordData data, bool pinned)
{
Keyword = data.keyword;
IsPinned = pinned;
// 1. 标题与标题栏显隐控制
bool hasTitle = !string.IsNullOrEmpty(data.keyword);
if (titleText != null)
titleText.text = data.keyword;
if (titleBarContainer != null)
titleBarContainer.gameObject.SetActive(hasTitle);
// 2. 描述(经过关键词处理,支持嵌套链接,排除自身防止自引用)
if (descriptionText != null)
{
string processed = KeywordProcessor.ProcessDescription(
data.description, data.keyword);
// 性能与排版双重防御:
// 获取或动态添加 LayoutElement 元素。在 Horizontal Layout Group 中,
// 如果不使用 LayoutElement.preferredWidth 限制,子节点的 TMP_Text 组件
// 会被 Layout 强制拉伸压缩至其 Minimum Width即单个中文字符宽度产生“过窄”Bug
var textLayout = descriptionText.GetComponent<LayoutElement>();
if (textLayout == null)
textLayout = descriptionText.gameObject.AddComponent<LayoutElement>();
var containerLayout = descriptionContainer != null ? descriptionContainer.GetComponent<LayoutElement>() : null;
if (descriptionContainer != null && containerLayout == null)
containerLayout = descriptionContainer.gameObject.AddComponent<LayoutElement>();
// 暂时关闭自动换行以计算其“自然无换行的 preferredWidth”
descriptionText.textWrappingMode = TextWrappingModes.NoWrap;
descriptionText.text = processed;
// 强制更新 TMPro 字形数据以获取精确的 preferredWidth
descriptionText.ForceMeshUpdate();
float preferredWidth = descriptionText.preferredWidth;
// 左右各缩减 10 像素,所以 padding 占用共 20 像素
float paddingWidth = 20f;
if (preferredWidth > 980f)
{
// 超过 980 像素,限制文本 preferredWidth 为 980并启用自动折行
textLayout.preferredWidth = 980f;
descriptionText.textWrappingMode = TextWrappingModes.Normal;
if (containerLayout != null)
containerLayout.preferredWidth = 1000f; // 容器宽度 = 980px + 20px padding
}
else
{
// 在 980 像素内,紧贴真实内容宽度展示,不进行折行
textLayout.preferredWidth = preferredWidth;
descriptionText.textWrappingMode = TextWrappingModes.NoWrap;
if (containerLayout != null)
containerLayout.preferredWidth = preferredWidth + paddingWidth;
}
}
// 3. 固定指示器与固定状态
UpdatePinIndicator();
// 4. 性能优化:只在内容加载、文本大小发生改变时强制刷新一次 UI 布局,防止跟随鼠标时每帧高频刷新
LayoutRebuilder.ForceRebuildLayoutImmediate(Rect);
}
// ---------------------------------------------------------------
// 固定 / 取消固定
// ---------------------------------------------------------------
/// <summary>
/// 将面板设为固定状态。固定后不再跟随鼠标,且不会因移开鼠标而自动关闭。
/// </summary>
public void Pin()
{
IsPinned = true;
UpdatePinIndicator();
}
/// <summary>
/// 取消固定状态。
/// </summary>
public void Unpin()
{
IsPinned = false;
UpdatePinIndicator();
}
// ---------------------------------------------------------------
// 屏幕定位
// ---------------------------------------------------------------
/// <summary>
/// 将面板定位到指定的屏幕坐标。
/// 默认情况下,面板左下角与鼠标对齐;
/// 在靠近屏幕边缘时,会自动调整到合适位置。
/// </summary>
/// <param name="screenPos">鼠标屏幕坐标</param>
/// <param name="offset">基础偏移量</param>
public void PositionAtScreenPoint(Vector2 screenPos, Vector2 offset)
{
if (Rect == null) return;
float panelWidth = Rect.rect.width;
float panelHeight = Rect.rect.height;
// 视觉边缘细节:引入 16 像素的安全屏幕边缘 padding防止边缘阴影或外发光被物理截边
float safeMargin = 16f;
// 基础定位:面板左下角对齐鼠标位置(鼠标在面板的左下角)
// screenPos 即面板的左下角坐标,再加一个小偏移
float posX = screenPos.x + offset.x;
float posY = screenPos.y + offset.y;
// 边缘自适应 ─ 右边界
if (panelWidth > 0 && posX + panelWidth > Screen.width - safeMargin)
{
// 面板会超出右侧 → 改为右下角对齐鼠标(面板在鼠标左侧)
posX = screenPos.x - panelWidth - Mathf.Abs(offset.x);
}
// 边缘自适应 ─ 左边界
if (posX < safeMargin)
{
posX = safeMargin;
}
// 边缘自适应 ─ 上边界
if (posY + panelHeight > Screen.height - safeMargin)
{
// 面板会超出上方 → 向下调整
posY = Screen.height - panelHeight - safeMargin;
}
// 边缘自适应 ─ 下边界
if (posY < safeMargin)
{
posY = safeMargin;
}
// 设置 Pivot 为左下角 (0, 0) 以匹配我们的定位逻辑
Rect.pivot = new Vector2(0f, 0f);
Rect.position = new Vector2(posX, posY);
}
// ---------------------------------------------------------------
// 碰撞检测
// ---------------------------------------------------------------
/// <summary>
/// 检测指定的屏幕坐标是否在面板区域内。
/// </summary>
public bool ContainsScreenPoint(Vector2 screenPos, Camera uiCamera)
{
return Rect != null &&
RectTransformUtility.RectangleContainsScreenPoint(Rect, screenPos, uiCamera);
}
// ---------------------------------------------------------------
// 内部方法
// ---------------------------------------------------------------
private void UpdatePinIndicator()
{
bool hasTitle = !string.IsNullOrEmpty(Keyword);
if (titlePin != null)
titlePin.SetActive(IsPinned && hasTitle);
if (descriptionPin != null)
descriptionPin.SetActive(IsPinned && !hasTitle);
}
}
}

View File

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

View File

@@ -0,0 +1,23 @@
{
"name": "SLSUtilities",
"rootNamespace": "",
"references": [
"GUID:174bc9f391f1b7f4292b3824c5019a21",
"GUID:560b04d1a97f54a4e82edc0cbbb69285",
"GUID:34aa492b82754644eac2f903cd496268",
"GUID:6055be8ebefd69e48b49212b09b47b2f",
"GUID:75469ad4d38634e559750d17036d5f7c",
"GUID:cfcd2ce455f8d1944942cdd919ecaa60",
"GUID:8017400dc3a8d3c4e8a805361276efd0",
"GUID:bf41a3c927b459f40a6588443b81113c"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 43bb4e992d9b32b4bbb25402b41e80a0
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +1,6 @@
using System.Collections.Generic;
using UnityEngine;
using AK.Wwise;
using Ichni;
using Lean.Pool;
using Sirenix.OdinInspector;
using SLSUtilities.General;
@@ -15,10 +14,7 @@ namespace SLSUtilities.WwiseAssistance
public GameObject audioPoint;
public List<Bank> soundBanks;
[Title("Subsystems")]
public SongPlayer backgroundMusicManager;
private static Dictionary<string, uint> TrackedPlayingIDs = new Dictionary<string, uint>();
private static Dictionary<string, uint> _trackedPlayingIDs = new Dictionary<string, uint>();
protected override void Awake()
{
@@ -88,7 +84,7 @@ namespace SLSUtilities.WwiseAssistance
public void StopAll()
{
AkUnitySoundEngine.StopAll();
TrackedPlayingIDs.Clear();
_trackedPlayingIDs.Clear();
}
}
@@ -98,7 +94,7 @@ namespace SLSUtilities.WwiseAssistance
{
if (playingID != AkUnitySoundEngine.AK_INVALID_PLAYING_ID)
{
TrackedPlayingIDs[trackingKey] = playingID;
_trackedPlayingIDs[trackingKey] = playingID;
}
}
@@ -129,7 +125,7 @@ namespace SLSUtilities.WwiseAssistance
public static void Pause(string trackingKey, int fadeOutMs = 0)
{
if (TrackedPlayingIDs.TryGetValue(trackingKey, out uint playingID))
if (_trackedPlayingIDs.TryGetValue(trackingKey, out uint playingID))
{
AkUnitySoundEngine.ExecuteActionOnPlayingID(
AkActionOnEventType.AkActionOnEventType_Pause,
@@ -141,7 +137,7 @@ namespace SLSUtilities.WwiseAssistance
public static void PauseAllTrackedEvents(int fadeOutMs = 0)
{
foreach (var playingID in TrackedPlayingIDs.Values)
foreach (var playingID in _trackedPlayingIDs.Values)
{
AkUnitySoundEngine.ExecuteActionOnPlayingID(
AkActionOnEventType.AkActionOnEventType_Pause,
@@ -154,7 +150,7 @@ namespace SLSUtilities.WwiseAssistance
// --- 【新增】核心控制:继续 ---
public static void Resume(string trackingKey, int fadeInMs = 0)
{
if (TrackedPlayingIDs.TryGetValue(trackingKey, out uint playingID))
if (_trackedPlayingIDs.TryGetValue(trackingKey, out uint playingID))
{
AkUnitySoundEngine.ExecuteActionOnPlayingID(
AkActionOnEventType.AkActionOnEventType_Resume,
@@ -166,7 +162,7 @@ namespace SLSUtilities.WwiseAssistance
public static void ResumeAllTrackedEvents(int fadeInMs = 0)
{
foreach (var playingID in TrackedPlayingIDs.Values)
foreach (var playingID in _trackedPlayingIDs.Values)
{
AkUnitySoundEngine.ExecuteActionOnPlayingID(
AkActionOnEventType.AkActionOnEventType_Resume,
@@ -181,10 +177,10 @@ namespace SLSUtilities.WwiseAssistance
/// </summary>
public static void Stop(string trackingKey, int fadeOutMs = 0)
{
if (TrackedPlayingIDs.TryGetValue(trackingKey, out uint playingID))
if (_trackedPlayingIDs.TryGetValue(trackingKey, out uint playingID))
{
Stop(playingID, fadeOutMs);
TrackedPlayingIDs.Remove(trackingKey);
_trackedPlayingIDs.Remove(trackingKey);
}
}
@@ -193,11 +189,11 @@ namespace SLSUtilities.WwiseAssistance
/// </summary>
public static void StopAllTrackedEvents(int fadeOutMs = 0)
{
foreach (var playingID in TrackedPlayingIDs.Values)
foreach (var playingID in _trackedPlayingIDs.Values)
{
Stop(playingID, fadeOutMs);
}
TrackedPlayingIDs.Clear();
_trackedPlayingIDs.Clear();
}
}