StorySystem
This commit is contained in:
93
Assets/Scripts/NewStorySystem/Dialogue/ChoiceButton.cs
Normal file
93
Assets/Scripts/NewStorySystem/Dialogue/ChoiceButton.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
using Sirenix.OdinInspector;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using Yarn.Unity;
|
||||
|
||||
namespace Ichni.Story.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// 单个对话选项按钮的显示与交互控制器。
|
||||
/// <para>
|
||||
/// <list type="bullet">
|
||||
/// <item><b>可用选项</b>:显示 <see cref="availableSprite"/>,点击时触发 <c>onSelected</c> 回调。</item>
|
||||
/// <item><b>不可用选项</b>:显示 <see cref="unavailableSprite"/>,点击无效(但 Button 的
|
||||
/// <c>interactable</c> 保持开启,以备未来扩展悬停提示等功能)。</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 由 <see cref="VNDialoguePresenter"/> 统一调用 <see cref="Setup"/> / <see cref="Cleanup"/>。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class ChoiceButton : MonoBehaviour
|
||||
{
|
||||
[Tooltip("按钮组件引用(挂载在同 GameObject 上的 Button)。")]
|
||||
public Button button;
|
||||
|
||||
[Tooltip("按钮背景 Image,用于在 available / unavailable 状态间切换 Sprite。")]
|
||||
public Image buttonBackground;
|
||||
|
||||
[Tooltip("选项文本 TMP。")]
|
||||
public TMP_Text buttonText;
|
||||
|
||||
[SerializeField, HideInPlayMode]
|
||||
[Tooltip("选项可用时的背景 Sprite。")]
|
||||
private Sprite availableSprite;
|
||||
|
||||
[SerializeField, HideInPlayMode]
|
||||
[Tooltip("选项不可用时的背景 Sprite。")]
|
||||
private Sprite unavailableSprite;
|
||||
|
||||
// ── 运行时状态 ────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>当前绑定的选项是否可供玩家选择。</summary>
|
||||
private bool _isAvailable;
|
||||
|
||||
// ── 公共 API ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 根据 <paramref name="option"/> 配置按钮并激活显示。
|
||||
/// </summary>
|
||||
/// <param name="option">Yarn 对话选项数据。<c>option.IsAvailable</c> 决定外观与交互行为。</param>
|
||||
/// <param name="onSelected">
|
||||
/// 玩家点击且选项可用时触发的回调。
|
||||
/// 不可用选项点击不会触发此回调,但按钮保持 <c>interactable=true</c>。
|
||||
/// </param>
|
||||
public void Setup(DialogueOption option, System.Action<DialogueOption> onSelected)
|
||||
{
|
||||
gameObject.SetActive(true);
|
||||
_isAvailable = option.IsAvailable;
|
||||
|
||||
// 设置选项文本
|
||||
if (buttonText != null)
|
||||
buttonText.text = option.Line.TextWithoutCharacterName.Text;
|
||||
|
||||
// 根据可用性切换背景图
|
||||
if (buttonBackground != null)
|
||||
buttonBackground.sprite = _isAvailable ? availableSprite : unavailableSprite;
|
||||
|
||||
// 注册点击监听:不可用选项点击静默忽略
|
||||
if (button != null)
|
||||
{
|
||||
button.onClick.RemoveAllListeners();
|
||||
button.onClick.AddListener(() =>
|
||||
{
|
||||
if (_isAvailable)
|
||||
onSelected?.Invoke(option);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清除按钮监听并隐藏此按钮。
|
||||
/// 在 <see cref="VNDialoguePresenter.RunOptionsAsync"/> 结束后统一调用。
|
||||
/// </summary>
|
||||
public void Cleanup()
|
||||
{
|
||||
if (button != null)
|
||||
button.onClick.RemoveAllListeners();
|
||||
|
||||
gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 63c53d8a6dc173a4db3d6a8a1499b7e6
|
||||
59
Assets/Scripts/NewStorySystem/Dialogue/IPortraitRenderer.cs
Normal file
59
Assets/Scripts/NewStorySystem/Dialogue/IPortraitRenderer.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Story.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// 立绘渲染器抽象接口。所有立绘渲染实现(Sprite / Live2D / Spine)均须实现此接口,
|
||||
/// 使 <see cref="VNPortraitStage"/> 和 Yarn 命令层无需感知底层渲染细节。
|
||||
/// </summary>
|
||||
public interface IPortraitRenderer
|
||||
{
|
||||
/// <summary>此渲染器在舞台上占用的 RectTransform(用于位置操控)。</summary>
|
||||
RectTransform RectTransform { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 以指定角色数据和情绪名显示立绘。若角色已在台上则更新情绪。
|
||||
/// </summary>
|
||||
void Show(CharacterData character, string emotion);
|
||||
|
||||
/// <summary>隐藏立绘(不销毁对象,留存以便复用)。</summary>
|
||||
void Hide();
|
||||
|
||||
/// <summary>切换当前情绪(角色数据不变)。</summary>
|
||||
void SetEmotion(string emotion);
|
||||
|
||||
/// <summary>
|
||||
/// 将立绘的 anchoredPosition 设为指定坐标(即时,无动画)。
|
||||
/// </summary>
|
||||
/// <param name="anchoredPos">目标 anchoredPosition(画布空间,像素单位)。</param>
|
||||
void SetPosition(Vector2 anchoredPos);
|
||||
|
||||
/// <summary>
|
||||
/// 控制说话者高亮状态:说话者 alpha = 1.0,旁观者 alpha = 0.7(可由实现自行定义)。
|
||||
/// </summary>
|
||||
void SetActiveVisual(bool isSpeaking);
|
||||
|
||||
/// <summary>
|
||||
/// 平滑移动到目标位置。
|
||||
/// </summary>
|
||||
/// <param name="anchoredPos">目标像素坐标</param>
|
||||
/// <param name="duration">持续时间</param>
|
||||
void MoveTo(Vector2 anchoredPos, float duration);
|
||||
|
||||
/// <summary>
|
||||
/// 原地跳跃动画。
|
||||
/// </summary>
|
||||
/// <param name="power">跳跃力度(高度)</param>
|
||||
/// <param name="numJumps">跳跃次数</param>
|
||||
/// <param name="duration">持续时间</param>
|
||||
void Jump(float power, int numJumps, float duration);
|
||||
|
||||
/// <summary>
|
||||
/// 左右震动动画。
|
||||
/// </summary>
|
||||
/// <param name="duration">持续时间</param>
|
||||
/// <param name="strength">震动力度(像素范围)</param>
|
||||
/// <param name="vibrato">震动频率</param>
|
||||
void Shake(float duration, float strength, int vibrato);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3dbfb11aa34a55942b604a15cbd65d50
|
||||
175
Assets/Scripts/NewStorySystem/Dialogue/SpritePortraitRenderer.cs
Normal file
175
Assets/Scripts/NewStorySystem/Dialogue/SpritePortraitRenderer.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using DG.Tweening;
|
||||
|
||||
namespace Ichni.Story.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// 基于 <see cref="UnityEngine.UI.Image"/> 的 Sprite 立绘渲染器。
|
||||
/// <para>
|
||||
/// 挂载在 VN 对话 UI 内的立绘槽位 GameObject 上(Left / Center / Right 等),
|
||||
/// 每个槽位对应一个 <see cref="SpritePortraitRenderer"/> 实例。
|
||||
/// 当前阶段仅实现静态显示(Show / Hide / SetEmotion / SetPosition / SetActiveVisual),
|
||||
/// 后续阶段再叠加 DOTween 动画。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Image))]
|
||||
public class SpritePortraitRenderer : MonoBehaviour, IPortraitRenderer
|
||||
{
|
||||
// ── 设置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>说话者的不透明度(满值)。</summary>
|
||||
[SerializeField] private float _alphaActive = 1.0f;
|
||||
|
||||
/// <summary>非说话者的压暗不透明度。</summary>
|
||||
[SerializeField] private float _alphaInactive = 0.7f;
|
||||
|
||||
// ── 组件缓存 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private Image _image;
|
||||
private RectTransform _rectTransform;
|
||||
|
||||
// ── 状态 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private CharacterData _currentCharacter;
|
||||
|
||||
// ── IPortraitRenderer 实现 ───────────────────────────────────────────────
|
||||
|
||||
/// <inheritdoc/>
|
||||
public RectTransform RectTransform => _rectTransform;
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_image = GetComponent<Image>();
|
||||
_rectTransform = GetComponent<RectTransform>();
|
||||
|
||||
// 初始隐藏
|
||||
SetGroupAlpha(0f);
|
||||
}
|
||||
|
||||
// ── 接口方法 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Show(CharacterData character, string emotion)
|
||||
{
|
||||
if (character == null)
|
||||
{
|
||||
Debug.LogWarning("[SpritePortraitRenderer] Show 传入了 null CharacterData。");
|
||||
return;
|
||||
}
|
||||
|
||||
_currentCharacter = character;
|
||||
ApplySprite(emotion);
|
||||
SetGroupAlpha(_alphaActive);
|
||||
gameObject.SetActive(true);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Hide()
|
||||
{
|
||||
SetGroupAlpha(0f);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void SetEmotion(string emotion)
|
||||
{
|
||||
if (_currentCharacter == null)
|
||||
{
|
||||
Debug.LogWarning("[SpritePortraitRenderer] SetEmotion 时尚未绑定角色数据。");
|
||||
return;
|
||||
}
|
||||
|
||||
ApplySprite(emotion);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void SetPosition(Vector2 anchoredPos)
|
||||
{
|
||||
_rectTransform.anchoredPosition = anchoredPos;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void SetActiveVisual(bool isSpeaking)
|
||||
{
|
||||
SetGroupAlpha(isSpeaking ? _alphaActive : _alphaInactive);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void MoveTo(Vector2 anchoredPos, float duration)
|
||||
{
|
||||
_rectTransform.DOKill(complete: true);
|
||||
_rectTransform.DOAnchorPos(anchoredPos, duration).SetEase(Ease.InOutQuad).Play();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Jump(float power, int numJumps, float duration)
|
||||
{
|
||||
// 对于跳跃,先停止并完成先前的动画,避免错乱(特别是被快进时)
|
||||
_rectTransform.DOKill(complete: true);
|
||||
|
||||
// UI 的 DOJumpAnchorPos 非常适合这种表现,项目中要求手动调用 Play()
|
||||
_rectTransform.DOJumpAnchorPos(_rectTransform.anchoredPosition, power, numJumps, duration).Play();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Shake(float duration, float strength, int vibrato)
|
||||
{
|
||||
_rectTransform.DOKill(complete: true);
|
||||
|
||||
// 左右震动(限制在 X 轴或以特定强度震动),项目中要求手动调用 Play()
|
||||
_rectTransform.DOShakeAnchorPos(duration, new Vector2(strength, 0f), vibrato, 90, false, true).Play();
|
||||
}
|
||||
|
||||
// ── 内部辅助 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 从 <see cref="CharacterData.emotionSprites"/> 中查找并赋值 Sprite。
|
||||
/// 找不到指定情绪时回退到"normal",再找不到则使用字典中第一个条目,
|
||||
/// 并在 Console 输出警告。
|
||||
/// </summary>
|
||||
private void ApplySprite(string emotion)
|
||||
{
|
||||
if (_currentCharacter.emotionSprites == null || _currentCharacter.emotionSprites.Count == 0)
|
||||
{
|
||||
Debug.LogWarning($"[SpritePortraitRenderer] 角色 '{_currentCharacter.characterId}' 没有配置任何情绪 Sprite。");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 精确匹配
|
||||
if (!string.IsNullOrEmpty(emotion) &&
|
||||
_currentCharacter.emotionSprites.TryGetValue(emotion, out Sprite target))
|
||||
{
|
||||
_image.sprite = target;
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 回退 normal
|
||||
if (_currentCharacter.emotionSprites.TryGetValue("normal", out Sprite fallback))
|
||||
{
|
||||
Debug.LogWarning($"[SpritePortraitRenderer] 角色 '{_currentCharacter.characterId}' 没有情绪 '{emotion}',回退到 'normal'。");
|
||||
_image.sprite = fallback;
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 使用第一个条目
|
||||
foreach (Sprite first in _currentCharacter.emotionSprites.Values)
|
||||
{
|
||||
Debug.LogWarning($"[SpritePortraitRenderer] 角色 '{_currentCharacter.characterId}' 也没有 'normal',使用第一张 Sprite。");
|
||||
_image.sprite = first;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetGroupAlpha(float alpha)
|
||||
{
|
||||
if (_image != null)
|
||||
{
|
||||
Color c = _image.color;
|
||||
c.a = alpha;
|
||||
_image.color = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 331609805c7de66409dba07cce2f3a28
|
||||
@@ -153,11 +153,17 @@ namespace Ichni.Story.Dialogue
|
||||
List<UnityAction> pending = new List<UnityAction>(dialogueEndActions);
|
||||
dialogueEndActions.Clear();
|
||||
foreach (UnityAction action in pending)
|
||||
{
|
||||
action?.Invoke();
|
||||
}
|
||||
|
||||
StoryMessageBoxUIPage.instance.FadeIn();
|
||||
}
|
||||
|
||||
if (storyUIPage != null)
|
||||
{
|
||||
storyUIPage.FadeIn();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using UnityEngine;
|
||||
using Yarn.Unity;
|
||||
|
||||
namespace Ichni.Story.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// 自定义 Yarn 变量存储。直接读写 <see cref="StorySaveModule.variables"/>(全局剧情变量),
|
||||
/// 让 Yarn 对话中的 <c><<set $var to ...>></c> 与故事树解锁条件共享同一份数据来源,
|
||||
/// 并借助存档模块的 ES3 序列化持久化。
|
||||
/// <para>变量名沿用 Yarn 约定(带 <c>$</c> 前缀);解锁条件中的变量名也应保持一致。</para>
|
||||
/// </summary>
|
||||
public class StoryVariableStorage : VariableStorageBehaviour
|
||||
{
|
||||
// GameSaveManager 尚未就绪(编辑器早期 / 场景未初始化)时的兜底容器
|
||||
private readonly StoryVariablesSave _fallback = new StoryVariablesSave();
|
||||
|
||||
/// <summary>当前生效的变量容器:优先取存档模块,缺失时用本地兜底容器。</summary>
|
||||
private StoryVariablesSave Variables
|
||||
{
|
||||
get
|
||||
{
|
||||
if (GameSaveManager.instance != null && GameSaveManager.instance.StorySaveModule != null)
|
||||
return GameSaveManager.instance.StorySaveModule.variables;
|
||||
return _fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// ── IVariableStorage 实现 ────────────────────────────────────────────────
|
||||
|
||||
public override bool TryGetValue<T>(string variableName, out T result)
|
||||
{
|
||||
StoryVariablesSave vars = Variables;
|
||||
|
||||
if (vars.floatVariables.TryGetValue(variableName, out float f))
|
||||
return TryConvert(f, out result);
|
||||
|
||||
if (vars.boolVariables.TryGetValue(variableName, out bool b))
|
||||
return TryConvert(b, out result);
|
||||
|
||||
if (vars.stringVariables.TryGetValue(variableName, out string s))
|
||||
return TryConvert(s, out result);
|
||||
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public override void SetValue(string variableName, string stringValue)
|
||||
{
|
||||
RemoveStaleEntries(variableName, keepFloat: false, keepString: true, keepBool: false);
|
||||
Variables.stringVariables[variableName] = stringValue;
|
||||
NotifyVariableChanged(variableName, stringValue);
|
||||
}
|
||||
|
||||
public override void SetValue(string variableName, float floatValue)
|
||||
{
|
||||
RemoveStaleEntries(variableName, keepFloat: true, keepString: false, keepBool: false);
|
||||
Variables.floatVariables[variableName] = floatValue;
|
||||
NotifyVariableChanged(variableName, floatValue);
|
||||
}
|
||||
|
||||
public override void SetValue(string variableName, bool boolValue)
|
||||
{
|
||||
RemoveStaleEntries(variableName, keepFloat: false, keepString: false, keepBool: true);
|
||||
Variables.boolVariables[variableName] = boolValue;
|
||||
NotifyVariableChanged(variableName, boolValue);
|
||||
}
|
||||
|
||||
public override bool Contains(string variableName)
|
||||
{
|
||||
StoryVariablesSave vars = Variables;
|
||||
return vars.floatVariables.ContainsKey(variableName)
|
||||
|| vars.boolVariables.ContainsKey(variableName)
|
||||
|| vars.stringVariables.ContainsKey(variableName);
|
||||
}
|
||||
|
||||
public override void Clear()
|
||||
{
|
||||
StoryVariablesSave vars = Variables;
|
||||
vars.floatVariables.Clear();
|
||||
vars.stringVariables.Clear();
|
||||
vars.boolVariables.Clear();
|
||||
}
|
||||
|
||||
public override void SetAllVariables(
|
||||
Dictionary<string, float> floats,
|
||||
Dictionary<string, string> strings,
|
||||
Dictionary<string, bool> bools,
|
||||
bool clear = true)
|
||||
{
|
||||
StoryVariablesSave vars = Variables;
|
||||
|
||||
if (clear)
|
||||
{
|
||||
vars.floatVariables.Clear();
|
||||
vars.stringVariables.Clear();
|
||||
vars.boolVariables.Clear();
|
||||
}
|
||||
|
||||
if (floats != null)
|
||||
foreach (KeyValuePair<string, float> kv in floats) vars.floatVariables[kv.Key] = kv.Value;
|
||||
if (strings != null)
|
||||
foreach (KeyValuePair<string, string> kv in strings) vars.stringVariables[kv.Key] = kv.Value;
|
||||
if (bools != null)
|
||||
foreach (KeyValuePair<string, bool> kv in bools) vars.boolVariables[kv.Key] = kv.Value;
|
||||
}
|
||||
|
||||
public override (Dictionary<string, float> FloatVariables,
|
||||
Dictionary<string, string> StringVariables,
|
||||
Dictionary<string, bool> BoolVariables) GetAllVariables()
|
||||
{
|
||||
StoryVariablesSave vars = Variables;
|
||||
return (
|
||||
new Dictionary<string, float>(vars.floatVariables),
|
||||
new Dictionary<string, string>(vars.stringVariables),
|
||||
new Dictionary<string, bool>(vars.boolVariables)
|
||||
);
|
||||
}
|
||||
|
||||
// ── 供故事树解锁条件复用的便捷读取 ────────────────────────────────────────
|
||||
|
||||
/// <summary>读取变量的整数近似值(float 四舍五入、bool 取 0/1);不存在返回 0。</summary>
|
||||
public int GetIntVariable(string variableName)
|
||||
{
|
||||
StoryVariablesSave vars = Variables;
|
||||
if (vars.floatVariables.TryGetValue(variableName, out float f))
|
||||
return Mathf.RoundToInt(f);
|
||||
if (vars.boolVariables.TryGetValue(variableName, out bool b))
|
||||
return b ? 1 : 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>该变量是否存在于任一类型的存储中。</summary>
|
||||
public bool HasVariable(string variableName) => Contains(variableName);
|
||||
|
||||
// ── 内部 ────────────────────────────────────────────────────────────────
|
||||
|
||||
// 当变量被重新赋值为其它类型时,从旧类型字典移除,避免同名变量残留多份
|
||||
private void RemoveStaleEntries(string name, bool keepFloat, bool keepString, bool keepBool)
|
||||
{
|
||||
StoryVariablesSave vars = Variables;
|
||||
if (!keepFloat) vars.floatVariables.Remove(name);
|
||||
if (!keepString) vars.stringVariables.Remove(name);
|
||||
if (!keepBool) vars.boolVariables.Remove(name);
|
||||
}
|
||||
|
||||
// 将存储中的原始值转换为 Yarn 请求的类型(float / bool / string / int 之间互转)
|
||||
private static bool TryConvert<T>(object value, out T result)
|
||||
{
|
||||
if (value is T typed)
|
||||
{
|
||||
result = typed;
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (typeof(T) == typeof(float) || typeof(T) == typeof(double) || typeof(T) == typeof(int))
|
||||
{
|
||||
float f = value switch
|
||||
{
|
||||
float fv => fv,
|
||||
bool bv => bv ? 1f : 0f,
|
||||
string sv => float.TryParse(sv, NumberStyles.Any, CultureInfo.InvariantCulture, out float p) ? p : 0f,
|
||||
_ => 0f
|
||||
};
|
||||
result = (T)System.Convert.ChangeType(f, typeof(T), CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(bool))
|
||||
{
|
||||
bool b = value switch
|
||||
{
|
||||
bool bv => bv,
|
||||
float fv => fv != 0f,
|
||||
string sv => bool.TryParse(sv, out bool p) && p,
|
||||
_ => false
|
||||
};
|
||||
result = (T)(object)b;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(string))
|
||||
{
|
||||
string s = value switch
|
||||
{
|
||||
string sv => sv,
|
||||
float fv => fv.ToString(CultureInfo.InvariantCulture),
|
||||
bool bv => bv.ToString(),
|
||||
_ => value != null ? value.ToString() : string.Empty
|
||||
};
|
||||
result = (T)(object)s;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 转换失败时落到下方失败分支
|
||||
}
|
||||
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7dc7dc14d9f97c043bf6fb67c3a70877
|
||||
@@ -3,6 +3,7 @@ using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using Yarn.Unity;
|
||||
using Ichni.Story.UI;
|
||||
|
||||
namespace Ichni.Story.Dialogue
|
||||
{
|
||||
@@ -11,61 +12,21 @@ namespace Ichni.Story.Dialogue
|
||||
/// <para>
|
||||
/// 职责:
|
||||
/// <list type="bullet">
|
||||
/// <item>对话开始/结束时淡入/淡出 <see cref="panelGroup"/>。</item>
|
||||
/// <item><see cref="RunLineAsync"/>:逐字打字效果 + 点击推进。</item>
|
||||
/// <item><see cref="RunOptionsAsync"/>:显示选项按钮 + 等待玩家选择。</item>
|
||||
/// <item>对话流控制:打字机效果、点击推进、等待选项。</item>
|
||||
/// <item>将所有的 UI 引用委托给 <see cref="DialogUIPage"/> 进行管理。</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 挂载位置:与 <see cref="DialogueRunner"/> 同一 GameObject(StoryDialogueRoot)。
|
||||
/// UI 引用字段需在 Inspector 中连接到 DialogPage 下的对应节点。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class VNDialoguePresenter : DialoguePresenterBase
|
||||
{
|
||||
// ── UI 引用 ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Header("面板控制")]
|
||||
[Tooltip("对话面板整体的 CanvasGroup(DialogPage/DialogCanvas)。用于淡入/淡出及交互开关。")]
|
||||
[SerializeField] private CanvasGroup panelGroup;
|
||||
|
||||
[Header("台词区")]
|
||||
[Tooltip("台词主体文本 TMP(DialogContentFrame/Background/Text)。")]
|
||||
[SerializeField] private TextMeshProUGUI dialogueText;
|
||||
|
||||
[Tooltip("说话者名称的父容器 GameObject(DialogContentFrame/Speaker)。无说话者时整体隐藏。")]
|
||||
[SerializeField] private GameObject speakerContainer;
|
||||
|
||||
[Tooltip("说话者名称 TMP(DialogContentFrame/Speaker/Text)。")]
|
||||
[SerializeField] private TextMeshProUGUI speakerText;
|
||||
|
||||
[Header("推进按钮")]
|
||||
[Tooltip("点击此按钮推进对话。\n" +
|
||||
" • 打字效果进行中 → 加速(RequestHurryUpLine)显示全文。\n" +
|
||||
" • 打字已完成 → 进入下一行(RequestNextLine)。\n" +
|
||||
"推荐:将场景中 DialogContentFrame/Background 或 BackButton 的 Button 拖入此处。")]
|
||||
[SerializeField] private Button advanceButton;
|
||||
|
||||
[Header("选项区")]
|
||||
[Tooltip("选项按钮容器根节点(ChoiceFrame)。有选项时显示,无选项时隐藏。")]
|
||||
[SerializeField] private GameObject choiceFrame;
|
||||
|
||||
[Tooltip("选项按钮数组(最多 4 个,对应 ChoiceFrame 下的四个 GameObject)。")]
|
||||
[SerializeField] private Button[] choiceButtons = new Button[4];
|
||||
|
||||
[Tooltip("每个选项按钮上的文本 TMP,与 choiceButtons 按索引一一对应。")]
|
||||
[SerializeField] private TextMeshProUGUI[] choiceTexts = new TextMeshProUGUI[4];
|
||||
|
||||
// ── 打字 / 淡入淡出设置 ──────────────────────────────────────────────────
|
||||
[Header("UI 管理")]
|
||||
[Tooltip("负责当前对话界面的 UI Page 容器。如果为空,将尝试获取全局单例。")]
|
||||
[SerializeField] private DialogUIPage uiPage;
|
||||
|
||||
[Header("打字效果")]
|
||||
[Tooltip("每秒显示的字符数。设为 0 则瞬间显示全文。")]
|
||||
[SerializeField] private float lettersPerSecond = 40f;
|
||||
|
||||
[Header("淡入淡出")]
|
||||
[SerializeField] private float fadeInDuration = 0.2f;
|
||||
[SerializeField] private float fadeOutDuration = 0.15f;
|
||||
|
||||
// ── 私有状态 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
@@ -74,6 +35,11 @@ namespace Ichni.Story.Dialogue
|
||||
/// </summary>
|
||||
private bool _typewriterComplete;
|
||||
|
||||
/// <summary>
|
||||
/// 是否处于快进状态(遇到选项或玩家再次点击时会打断)。
|
||||
/// </summary>
|
||||
private bool _isFastForwarding;
|
||||
|
||||
/// <summary>
|
||||
/// 选项选择的异步结果来源。RunOptionsAsync 等待其 Task 完成,
|
||||
/// 点击按钮时 TrySetResult 触发完成。
|
||||
@@ -81,179 +47,180 @@ namespace Ichni.Story.Dialogue
|
||||
private YarnTaskCompletionSource<DialogueOption?> _optionTcs;
|
||||
|
||||
/// <summary>
|
||||
/// 对应 StoryDialogueRoot 上的 DialogueRunner,用于调用 RequestNextLine / RequestHurryUpLine。
|
||||
/// 在 Awake 中通过 GetComponent 取得。
|
||||
/// 对应 StoryDialogueRoot 上的 DialogueRunner。
|
||||
/// </summary>
|
||||
private DialogueRunner _runner;
|
||||
|
||||
private DialogUIPage Page => uiPage != null ? uiPage : DialogUIPage.instance;
|
||||
|
||||
// ── 生命周期 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// VNDialoguePresenter 需要与 DialogueRunner 同 GameObject,通过 GetComponent 获取引用。
|
||||
_runner = GetComponent<DialogueRunner>();
|
||||
if (_runner == null)
|
||||
Debug.LogWarning("[VNDialoguePresenter] 未在同 GameObject 上找到 DialogueRunner。" +
|
||||
"请确认 VNDialoguePresenter 挂载在 StoryDialogueRoot 上。");
|
||||
}
|
||||
|
||||
// 启动时隐藏面板(alpha=0, 不响应交互)
|
||||
SetPanelState(visible: false, instant: true);
|
||||
private void Start()
|
||||
{
|
||||
if (Page == null) return;
|
||||
|
||||
// 启动时隐藏选项容器
|
||||
if (choiceFrame != null) choiceFrame.SetActive(false);
|
||||
if (Page.choiceFrame != null) Page.choiceFrame.SetActive(false);
|
||||
|
||||
// 注册推进按钮回调
|
||||
if (advanceButton != null)
|
||||
advanceButton.onClick.AddListener(OnAdvanceButtonClicked);
|
||||
if (Page.advanceButton != null)
|
||||
Page.advanceButton.onClick.AddListener(OnAdvanceButtonClicked);
|
||||
|
||||
// 注册快进按钮回调
|
||||
if (Page.fastForwardButton != null)
|
||||
Page.fastForwardButton.onClick.AddListener(OnFastForwardButtonClicked);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (advanceButton != null)
|
||||
advanceButton.onClick.RemoveListener(OnAdvanceButtonClicked);
|
||||
if (Page != null)
|
||||
{
|
||||
if (Page.advanceButton != null)
|
||||
Page.advanceButton.onClick.RemoveListener(OnAdvanceButtonClicked);
|
||||
if (Page.fastForwardButton != null)
|
||||
Page.fastForwardButton.onClick.RemoveListener(OnFastForwardButtonClicked);
|
||||
}
|
||||
}
|
||||
|
||||
// ── DialoguePresenterBase 实现 ───────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 对话开始:将面板淡入到可见状态。
|
||||
/// 在 DialogueRunner 开始第一行前由 Yarn 调用。
|
||||
/// </summary>
|
||||
public override async YarnTask OnDialogueStartedAsync()
|
||||
{
|
||||
// 先令 CanvasGroup 可交互但 alpha=0(为淡入动画做准备)
|
||||
SetPanelState(visible: true, instant: false);
|
||||
|
||||
if (panelGroup != null)
|
||||
await Effects.FadeAlphaAsync(panelGroup, 0f, 1f, fadeInDuration, CancellationToken.None);
|
||||
if (Page != null)
|
||||
{
|
||||
var tcs = new YarnTaskCompletionSource<bool>();
|
||||
Page.PlayFadeIn(() => tcs.TrySetResult(true));
|
||||
await tcs.Task;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 呈现一行台词。
|
||||
/// <list type="number">
|
||||
/// <item>隐藏选项区,显示说话者名称(若有)。</item>
|
||||
/// <item>逐字打字效果,期间可被 <c>HurryUpToken</c> 中断(显示全文)。</item>
|
||||
/// <item>等待玩家点击推进(<c>NextContentToken</c> 由 <see cref="DialogueRunner.RequestNextLine"/> 取消)。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public override async YarnTask RunLineAsync(LocalizedLine line, LineCancellationToken token)
|
||||
{
|
||||
if (Page == null) return;
|
||||
|
||||
// 确保选项区不可见;台词区始终可见
|
||||
if (choiceFrame != null) choiceFrame.SetActive(false);
|
||||
if (Page.choiceFrame != null) Page.choiceFrame.SetActive(false);
|
||||
|
||||
// ── 说话者名称 ──
|
||||
bool hasSpeaker = !string.IsNullOrEmpty(line.CharacterName);
|
||||
if (speakerContainer != null) speakerContainer.SetActive(hasSpeaker);
|
||||
if (speakerText != null) speakerText.text = hasSpeaker ? line.CharacterName : string.Empty;
|
||||
if (Page.speakerContainer != null) Page.speakerContainer.SetActive(hasSpeaker);
|
||||
if (Page.speakerText != null) Page.speakerText.text = hasSpeaker ? line.CharacterName : string.Empty;
|
||||
|
||||
// ── 立绘高亮 ──
|
||||
Page.portraitStage?.HighlightSpeaker(line.CharacterName);
|
||||
|
||||
// ── 打字效果 ──
|
||||
string fullText = line.TextWithoutCharacterName.Text;
|
||||
_typewriterComplete = false;
|
||||
|
||||
if (dialogueText != null)
|
||||
if (Page.dialogueText != null)
|
||||
{
|
||||
// 先将全文赋给 TMP,再通过 maxVisibleCharacters 逐步显示
|
||||
// (避免每帧 Substring 产生大量 GC 分配)
|
||||
dialogueText.text = fullText;
|
||||
dialogueText.maxVisibleCharacters = 0;
|
||||
Page.dialogueText.text = fullText;
|
||||
Page.dialogueText.maxVisibleCharacters = 0;
|
||||
}
|
||||
|
||||
// 逐字播放;若 HurryUpToken 被取消(玩家第一次点击)则立即跳出循环
|
||||
await TypewriterAsync(fullText, token.HurryUpToken);
|
||||
if (_isFastForwarding)
|
||||
{
|
||||
// 瞬间显示全文
|
||||
if (Page.dialogueText != null) Page.dialogueText.maxVisibleCharacters = int.MaxValue;
|
||||
_typewriterComplete = true;
|
||||
|
||||
// 确保全文可见(无论正常完成还是被加速中断)
|
||||
if (dialogueText != null)
|
||||
dialogueText.maxVisibleCharacters = int.MaxValue;
|
||||
// 极短的延迟,防止瞬间刷过几百句话造成卡顿,也给玩家微小的视觉感知
|
||||
await YarnTask.Delay(50).SuppressCancellationThrow();
|
||||
|
||||
_typewriterComplete = true;
|
||||
if (_isFastForwarding && _runner != null)
|
||||
{
|
||||
_runner.RequestNextLine();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 正常逐字播放
|
||||
await TypewriterAsync(fullText, token.HurryUpToken);
|
||||
|
||||
// 确保全文可见
|
||||
if (Page.dialogueText != null)
|
||||
Page.dialogueText.maxVisibleCharacters = int.MaxValue;
|
||||
|
||||
_typewriterComplete = true;
|
||||
}
|
||||
|
||||
// ── 等待玩家推进 ──
|
||||
// token.NextContentToken 由外部 DialogueRunner.RequestNextLine() 取消,
|
||||
// SuppressCancellationThrow 确保不抛出 OperationCanceledException。
|
||||
await YarnTask.WaitUntilCanceled(token.NextContentToken).SuppressCancellationThrow();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 呈现选项列表,等待玩家选择。
|
||||
/// <list type="number">
|
||||
/// <item>显示选项容器,配置每个按钮的文本与点击监听。</item>
|
||||
/// <item>通过 <see cref="YarnTaskCompletionSource{T}"/> 异步等待玩家选择。</item>
|
||||
/// <item>返回玩家选择的 <see cref="DialogueOption"/>(对话被外部取消则返回 null)。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public override async YarnTask<DialogueOption?> RunOptionsAsync(
|
||||
DialogueOption[] options,
|
||||
LineCancellationToken token)
|
||||
{
|
||||
if (Page == null) return null;
|
||||
|
||||
// 遇到选项强制打断快进
|
||||
_isFastForwarding = false;
|
||||
|
||||
// 清空台词区,准备显示选项
|
||||
if (speakerContainer != null) speakerContainer.SetActive(false);
|
||||
if (dialogueText != null) dialogueText.text = string.Empty;
|
||||
if (Page.speakerContainer != null) Page.speakerContainer.SetActive(false);
|
||||
if (Page.dialogueText != null) Page.dialogueText.text = string.Empty;
|
||||
|
||||
// 显示选项容器
|
||||
if (choiceFrame != null) choiceFrame.SetActive(true);
|
||||
if (Page.choiceFrame != null) Page.choiceFrame.SetActive(true);
|
||||
|
||||
// 创建选项选择的异步结果来源
|
||||
_optionTcs = new YarnTaskCompletionSource<DialogueOption?>();
|
||||
|
||||
// 当 NextContentToken 被外部取消(对话被强制结束),提前将 TCS 置为 null 结果,
|
||||
// 使 await _optionTcs.Task 能正常退出而不死锁。
|
||||
WaitForExternalCancelAsync(token.NextContentToken).Forget();
|
||||
|
||||
// ── 配置每个选项按钮 ──
|
||||
for (int i = 0; i < choiceButtons.Length; i++)
|
||||
for (int i = 0; i < Page.choiceButtons.Length; i++)
|
||||
{
|
||||
// 只显示当前实际有效且可用的选项
|
||||
bool active = i < options.Length && options[i].IsAvailable;
|
||||
if (choiceButtons[i] != null)
|
||||
choiceButtons[i].gameObject.SetActive(active);
|
||||
if (Page.choiceButtons[i] == null) continue;
|
||||
|
||||
if (!active) continue;
|
||||
|
||||
// 设置按钮文本
|
||||
if (i < choiceTexts.Length && choiceTexts[i] != null)
|
||||
choiceTexts[i].text = options[i].Line.TextWithoutCharacterName.Text;
|
||||
|
||||
// 捕获循环变量,避免闭包捕获错误索引
|
||||
DialogueOption captured = options[i];
|
||||
choiceButtons[i].onClick.RemoveAllListeners();
|
||||
choiceButtons[i].onClick.AddListener(() => _optionTcs.TrySetResult(captured));
|
||||
if (i < options.Length)
|
||||
Page.choiceButtons[i].Setup(options[i], opt => _optionTcs.TrySetResult(opt));
|
||||
else
|
||||
Page.choiceButtons[i].Cleanup();
|
||||
}
|
||||
|
||||
// ── 等待玩家选择 ──
|
||||
DialogueOption? selected = await _optionTcs.Task;
|
||||
|
||||
// 清理按钮监听,隐藏选项容器
|
||||
foreach (Button btn in choiceButtons)
|
||||
if (btn != null) btn.onClick.RemoveAllListeners();
|
||||
// 清理所有按钮并隐藏选项容器
|
||||
foreach (ChoiceButton btn in Page.choiceButtons)
|
||||
btn?.Cleanup();
|
||||
|
||||
if (choiceFrame != null) choiceFrame.SetActive(false);
|
||||
if (Page.choiceFrame != null) Page.choiceFrame.SetActive(false);
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 对话结束:淡出并隐藏对话面板。
|
||||
/// 在 DialogueRunner 处理完最后一行/选项后由 Yarn 调用。
|
||||
/// </summary>
|
||||
public override async YarnTask OnDialogueCompleteAsync()
|
||||
{
|
||||
if (panelGroup != null)
|
||||
await Effects.FadeAlphaAsync(panelGroup, 1f, 0f, fadeOutDuration, CancellationToken.None);
|
||||
|
||||
SetPanelState(visible: false, instant: true);
|
||||
if (Page != null)
|
||||
{
|
||||
var tcs = new YarnTaskCompletionSource<bool>();
|
||||
Page.PlayFadeOut(() => tcs.TrySetResult(true));
|
||||
await tcs.Task;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 内部方法 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 推进按钮点击回调。
|
||||
/// <list type="bullet">
|
||||
/// <item>打字中 → RequestHurryUpLine(取消 HurryUpToken,立即显示全文)。</item>
|
||||
/// <item>打字完毕 → RequestNextLine(取消 NextContentToken,进入下一行)。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private void OnAdvanceButtonClicked()
|
||||
{
|
||||
// 如果玩家在快进时点击屏幕/推进键,则打断快进状态并停止当前动作
|
||||
if (_isFastForwarding)
|
||||
{
|
||||
_isFastForwarding = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_runner == null) return;
|
||||
|
||||
if (!_typewriterComplete)
|
||||
@@ -262,55 +229,40 @@ namespace Ichni.Story.Dialogue
|
||||
_runner.RequestNextLine(); // 第二次点击:推进到下一行
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 控制对话面板 <see cref="panelGroup"/> 的显示状态。
|
||||
/// </summary>
|
||||
/// <param name="visible">是否可见且可交互。</param>
|
||||
/// <param name="instant">为 true 时立即设置 alpha;为 false 时仅设置 interactable/blocksRaycasts,
|
||||
/// alpha 留给外部的 <c>FadeAlphaAsync</c> 动画处理。</param>
|
||||
private void SetPanelState(bool visible, bool instant)
|
||||
private void OnFastForwardButtonClicked()
|
||||
{
|
||||
if (panelGroup == null) return;
|
||||
panelGroup.interactable = visible;
|
||||
panelGroup.blocksRaycasts = visible;
|
||||
if (instant) panelGroup.alpha = visible ? 1f : 0f;
|
||||
_isFastForwarding = true;
|
||||
|
||||
if (_runner == null) return;
|
||||
|
||||
// 立即跳过当前的等待状态
|
||||
if (!_typewriterComplete)
|
||||
_runner.RequestHurryUpLine();
|
||||
else
|
||||
_runner.RequestNextLine();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 逐字打字效果。
|
||||
/// 通过 <c>maxVisibleCharacters</c> 逐步显示文本(无 GC 分配)。
|
||||
/// 被 <paramref name="hurryUpToken"/> 取消时立即返回,调用方负责将 maxVisibleCharacters 设为 MaxValue。
|
||||
/// </summary>
|
||||
/// <param name="text">要逐字展示的完整文本(已预先赋值给 dialogueText.text)。</param>
|
||||
/// <param name="hurryUpToken">取消此 token 可跳过打字效果。</param>
|
||||
private async YarnTask TypewriterAsync(string text, CancellationToken hurryUpToken)
|
||||
{
|
||||
if (dialogueText == null) return;
|
||||
if (Page.dialogueText == null) return;
|
||||
|
||||
// lettersPerSecond <= 0 时瞬间显示全文
|
||||
if (lettersPerSecond <= 0f)
|
||||
{
|
||||
dialogueText.maxVisibleCharacters = int.MaxValue;
|
||||
Page.dialogueText.maxVisibleCharacters = int.MaxValue;
|
||||
return;
|
||||
}
|
||||
|
||||
int delayMs = Mathf.Max(1, Mathf.RoundToInt(1000f / lettersPerSecond));
|
||||
|
||||
// TMP 的 maxVisibleCharacters 控制可见字符数(不含 rich-text 标签)
|
||||
// 逐帧递增直到全文显示或被 hurryUpToken 中断
|
||||
for (int i = 1; i <= text.Length; i++)
|
||||
{
|
||||
if (hurryUpToken.IsCancellationRequested) return;
|
||||
dialogueText.maxVisibleCharacters = i;
|
||||
Page.dialogueText.maxVisibleCharacters = i;
|
||||
await YarnTask.Delay(delayMs, hurryUpToken).SuppressCancellationThrow();
|
||||
if (hurryUpToken.IsCancellationRequested) return;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 监听外部取消信号(对话被强制终止),将 <see cref="_optionTcs"/> 设为 null 结果,
|
||||
/// 确保 <see cref="RunOptionsAsync"/> 的 await 能正常退出而不会死锁。
|
||||
/// </summary>
|
||||
private async YarnTask WaitForExternalCancelAsync(CancellationToken externalToken)
|
||||
{
|
||||
await YarnTask.WaitUntilCanceled(externalToken).SuppressCancellationThrow();
|
||||
|
||||
334
Assets/Scripts/NewStorySystem/Dialogue/VNPortraitStage.cs
Normal file
334
Assets/Scripts/NewStorySystem/Dialogue/VNPortraitStage.cs
Normal file
@@ -0,0 +1,334 @@
|
||||
using System.Collections.Generic;
|
||||
using Sirenix.OdinInspector;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Story.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// VN 立绘舞台。管理当前对话中所有已上台角色的 <see cref="IPortraitRenderer"/> 实例。
|
||||
/// 依据用户的合理建议,为了支持未知数量的角色同时出现,本系统改为【动态生成与移除】机制:
|
||||
/// <list type="bullet">
|
||||
/// <item>当角色需要上台时,依据其 <see cref="CharacterData.portraitStyle"/> 动态实例化 Prefab。</item>
|
||||
/// <item>当角色下台时,直接销毁(Destroy)对应的 GameObject 释放内存。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public class VNPortraitStage : MonoBehaviour
|
||||
{
|
||||
public static VNPortraitStage Instance { get; private set; }
|
||||
|
||||
// ── 配置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Header("Prefab Settings")]
|
||||
[Tooltip("默认的 Sprite 立绘预制体。当角色的 Portrait Style 为 Sprite 时使用此预制体进行实例化。" +
|
||||
"该预制体上必须挂载了实现 IPortraitRenderer 的组件(如 SpritePortraitRenderer)。")]
|
||||
[SerializeField]
|
||||
private GameObject _spritePortraitPrefab;
|
||||
|
||||
[Header("Canvas Reference")]
|
||||
[Tooltip("立绘容器的 RectTransform,作为所有生成立绘的父节点。也是坐标映射的基准(通常指向 DialogPage/PortraitContainer)。")]
|
||||
[SerializeField]
|
||||
private RectTransform _portraitContainer;
|
||||
|
||||
// ── 运行时状态 ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>当前已上台的活跃角色:characterId → 其对应的渲染器实例</summary>
|
||||
private readonly Dictionary<string, IPortraitRenderer> _activePortraits =
|
||||
new Dictionary<string, IPortraitRenderer>();
|
||||
|
||||
// ── 生命周期 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (Instance == this)
|
||||
{
|
||||
Instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 公共 API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 当前说话者角色 Id(由 Yarn 行数据驱动)。
|
||||
/// </summary>
|
||||
public string CurrentSpeakerId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 显示角色立绘。如果角色已在台上则更新情绪和位置;
|
||||
/// 如果不在台上,则根据其渲染类型动态生成,并置于容器内。
|
||||
/// 这里的坐标是归一化坐标 (-100, 100),内部会自动加上该角色的 positionOffset。
|
||||
/// </summary>
|
||||
/// <param name="characterId">角色 ID(需在 CharacterRegistry 中存在)。</param>
|
||||
/// <param name="emotion">初始情绪名。</param>
|
||||
/// <param name="normalizedPosition">目标归一化坐标 [-100, 100]。</param>
|
||||
/// <returns>成功生成或更新返回 true,失败返回 false。</returns>
|
||||
public bool ShowPortrait(string characterId, string emotion, Vector2 normalizedPosition)
|
||||
{
|
||||
CharacterData data = GetCharacterData(characterId);
|
||||
if (data == null) return false;
|
||||
|
||||
// 1. 计算最终的像素坐标(应用 CharacterData 中配置的 positionOffset)
|
||||
Vector2 finalNormalizedPos = normalizedPosition + data.positionOffset;
|
||||
Vector2 pixelPos = NormalizedToAnchoredPosition(finalNormalizedPos.x, finalNormalizedPos.y);
|
||||
|
||||
// 2. 如果角色已在台上,直接更新情绪与位置
|
||||
if (_activePortraits.TryGetValue(characterId, out IPortraitRenderer existing))
|
||||
{
|
||||
existing.SetEmotion(emotion);
|
||||
existing.SetPosition(pixelPos);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. 如果不在台上,决定使用哪一个预制体进行动态生成
|
||||
GameObject prefabToInstantiate = null;
|
||||
|
||||
if (data.portraitStyle == PortraitStyle.Sprite)
|
||||
{
|
||||
prefabToInstantiate = _spritePortraitPrefab;
|
||||
if (prefabToInstantiate == null)
|
||||
{
|
||||
Debug.LogError("[VNPortraitStage] 未配置 _spritePortraitPrefab,无法动态生成 Sprite 立绘!");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else // Live2D / Spine
|
||||
{
|
||||
prefabToInstantiate = data.dynamicPortraitPrefab;
|
||||
if (prefabToInstantiate == null)
|
||||
{
|
||||
Debug.LogError($"[VNPortraitStage] 角色 '{characterId}' 为动态立绘,但 CharacterData 中未配置 dynamicPortraitPrefab!");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 动态实例化
|
||||
GameObject go = Instantiate(prefabToInstantiate, _portraitContainer);
|
||||
go.name = $"Portrait_{characterId}";
|
||||
|
||||
// 确保坐标系和缩放正确
|
||||
RectTransform rect = go.GetComponent<RectTransform>();
|
||||
if (rect != null)
|
||||
{
|
||||
rect.localScale = Vector3.one;
|
||||
rect.localRotation = Quaternion.identity;
|
||||
}
|
||||
|
||||
IPortraitRenderer renderer = go.GetComponent<IPortraitRenderer>();
|
||||
if (renderer == null)
|
||||
{
|
||||
Debug.LogError($"[VNPortraitStage] 生成的预制体 '{go.name}' 缺少实现 IPortraitRenderer 接口的组件!");
|
||||
Destroy(go);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. 显示并定位
|
||||
renderer.Show(data, emotion);
|
||||
renderer.SetPosition(pixelPos);
|
||||
|
||||
// 5. 登记到活跃立绘列表
|
||||
_activePortraits[characterId] = renderer;
|
||||
|
||||
// 6. 保持当前说话者的高亮状态一致
|
||||
renderer.SetActiveVisual(characterId == CurrentSpeakerId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 移除并销毁指定角色的立绘。
|
||||
/// </summary>
|
||||
public void HidePortrait(string characterId)
|
||||
{
|
||||
if (_activePortraits.TryGetValue(characterId, out IPortraitRenderer renderer))
|
||||
{
|
||||
if (renderer is MonoBehaviour mb && mb != null)
|
||||
{
|
||||
Destroy(mb.gameObject);
|
||||
}
|
||||
_activePortraits.Remove(characterId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清空并销毁所有当前在台上的立绘。
|
||||
/// </summary>
|
||||
public void HideAll()
|
||||
{
|
||||
foreach (IPortraitRenderer renderer in _activePortraits.Values)
|
||||
{
|
||||
if (renderer is MonoBehaviour mb && mb != null)
|
||||
{
|
||||
Destroy(mb.gameObject);
|
||||
}
|
||||
}
|
||||
_activePortraits.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置或移动立绘位置,并可选择性地更新表情。如果角色不在台上,则会动态生成它。
|
||||
/// </summary>
|
||||
/// <param name="characterId">角色 ID。</param>
|
||||
/// <param name="normalizedPosition">目标归一化坐标 [-100, 100],会自动应用角色的 positionOffset。</param>
|
||||
/// <param name="emotion">可选表情。如果为 null 且角色已在台上,则保持当前表情;若角色不在台上,则默认使用 "normal"。</param>
|
||||
public void SetPortraitPosition(string characterId, Vector2 normalizedPosition, string emotion = null)
|
||||
{
|
||||
CharacterData data = GetCharacterData(characterId);
|
||||
if (data == null) return;
|
||||
|
||||
// 计算包含角色特定偏移量的最终像素坐标
|
||||
Vector2 finalNormalizedPos = normalizedPosition + data.positionOffset;
|
||||
Vector2 pixelPos = NormalizedToAnchoredPosition(finalNormalizedPos.x, finalNormalizedPos.y);
|
||||
|
||||
if (_activePortraits.TryGetValue(characterId, out IPortraitRenderer renderer))
|
||||
{
|
||||
renderer.SetPosition(pixelPos);
|
||||
if (!string.IsNullOrEmpty(emotion))
|
||||
{
|
||||
renderer.SetEmotion(emotion);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 不在台上,则用指定表情(空时默认 "normal")在目标位置生成它
|
||||
string targetEmotion = string.IsNullOrEmpty(emotion) ? "normal" : emotion;
|
||||
ShowPortrait(characterId, targetEmotion, normalizedPosition);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 说话者高亮压暗处理:当前说话者高亮,其余压暗。
|
||||
/// </summary>
|
||||
public void HighlightSpeaker(string characterId)
|
||||
{
|
||||
CurrentSpeakerId = characterId;
|
||||
|
||||
foreach (KeyValuePair<string, IPortraitRenderer> kv in _activePortraits)
|
||||
{
|
||||
kv.Value.SetActiveVisual(kv.Key == characterId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 仅改变角色的表情(角色必须已在台上)。
|
||||
/// </summary>
|
||||
public void SetPortraitEmotion(string characterId, string emotion)
|
||||
{
|
||||
if (_activePortraits.TryGetValue(characterId, out IPortraitRenderer renderer))
|
||||
{
|
||||
renderer.SetEmotion(emotion);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[VNPortraitStage] 无法仅设置表情:角色 '{characterId}' 目前不在台上。");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 平滑移动立绘到目标归一化坐标。
|
||||
/// </summary>
|
||||
public void MovePortrait(string characterId, Vector2 normalizedPosition, float duration)
|
||||
{
|
||||
if (_activePortraits.TryGetValue(characterId, out IPortraitRenderer renderer))
|
||||
{
|
||||
CharacterData data = GetCharacterData(characterId);
|
||||
if (data != null)
|
||||
{
|
||||
Vector2 finalNormalizedPos = normalizedPosition + data.positionOffset;
|
||||
Vector2 pixelPos = NormalizedToAnchoredPosition(finalNormalizedPos.x, finalNormalizedPos.y);
|
||||
renderer.MoveTo(pixelPos, duration);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[VNPortraitStage] 无法移动:角色 '{characterId}' 目前不在台上。");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 让指定角色的立绘跳跃。
|
||||
/// </summary>
|
||||
/// <param name="normalizedPower">跳跃高度(归一化坐标尺度)</param>
|
||||
public void JumpPortrait(string characterId, float normalizedPower, int numJumps, float duration)
|
||||
{
|
||||
if (_activePortraits.TryGetValue(characterId, out IPortraitRenderer renderer))
|
||||
{
|
||||
float pixelPower = NormalizedToPixelDistance(normalizedPower, false); // 跳跃基于 Y 轴高度
|
||||
renderer.Jump(pixelPower, numJumps, duration);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[VNPortraitStage] 无法跳跃:角色 '{characterId}' 目前不在台上。");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 让指定角色的立绘震动。
|
||||
/// </summary>
|
||||
/// <param name="normalizedStrength">震动幅度(归一化坐标尺度)</param>
|
||||
public void ShakePortrait(string characterId, float duration, float normalizedStrength, int vibrato)
|
||||
{
|
||||
if (_activePortraits.TryGetValue(characterId, out IPortraitRenderer renderer))
|
||||
{
|
||||
float pixelStrength = NormalizedToPixelDistance(normalizedStrength, true); // 左右震动基于 X 轴宽度
|
||||
renderer.Shake(duration, pixelStrength, vibrato);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[VNPortraitStage] 无法震动:角色 '{characterId}' 目前不在台上。");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将归一化坐标 (-100, 100) 映射为 anchoredPosition 像素坐标。
|
||||
/// </summary>
|
||||
public Vector2 NormalizedToAnchoredPosition(float normalizedX, float normalizedY)
|
||||
{
|
||||
if (_portraitContainer == null)
|
||||
{
|
||||
Debug.LogWarning("[VNPortraitStage] _portraitContainer 未配置,坐标映射使用 1920x1080 默认尺寸映射。");
|
||||
return new Vector2(normalizedX / 100f * 960f, normalizedY / 100f * 540f);
|
||||
}
|
||||
|
||||
Vector2 halfSize = _portraitContainer.rect.size * 0.5f;
|
||||
return new Vector2(normalizedX / 100f * halfSize.x, normalizedY / 100f * halfSize.y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将归一化距离映射为像素距离(用于动画幅度换算)。
|
||||
/// </summary>
|
||||
/// <param name="normalizedDistance">归一化长度</param>
|
||||
/// <param name="useXAxis">是否基于 X 轴进行映射(否则基于 Y 轴)</param>
|
||||
public float NormalizedToPixelDistance(float normalizedDistance, bool useXAxis = false)
|
||||
{
|
||||
if (_portraitContainer == null)
|
||||
{
|
||||
return normalizedDistance / 100f * (useXAxis ? 960f : 540f);
|
||||
}
|
||||
Vector2 halfSize = _portraitContainer.rect.size * 0.5f;
|
||||
return normalizedDistance / 100f * (useXAxis ? halfSize.x : halfSize.y);
|
||||
}
|
||||
|
||||
// ── 内部辅助 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private CharacterData GetCharacterData(string characterId)
|
||||
{
|
||||
if (StoryManager.instance == null || StoryManager.instance.characterRegistry == null)
|
||||
{
|
||||
Debug.LogError("[VNPortraitStage] StoryManager 或 CharacterRegistry 未初始化!");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!StoryManager.instance.characterRegistry.TryGet(characterId, out CharacterData data))
|
||||
{
|
||||
Debug.LogWarning($"[VNPortraitStage] 在 CharacterRegistry 中找不到 ID 为 '{characterId}' 的 CharacterData 资产。");
|
||||
return null;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ec4abf93d4e3d1d448630a311fe7c236
|
||||
Reference in New Issue
Block a user