129 lines
4.8 KiB
C#
129 lines
4.8 KiB
C#
using System.Collections;
|
||
using System.Collections.Generic;
|
||
using TMPro;
|
||
using UnityEngine;
|
||
using BaseGames.Core;
|
||
using BaseGames.Core.Events;
|
||
|
||
namespace BaseGames.UI
|
||
{
|
||
/// <summary>屏幕阅读器优先级(与 WCAG / ARIA live-region 等级对齐)。</summary>
|
||
public enum ScreenReaderPriority
|
||
{
|
||
/// <summary>Polite —— 当前播报结束后再播报,不打断玩家。</summary>
|
||
Polite = 0,
|
||
/// <summary>Assertive —— 立即打断当前播报(如战斗死亡、关键提示)。</summary>
|
||
Assertive = 1
|
||
}
|
||
|
||
/// <summary>
|
||
/// 屏幕阅读器接口(无障碍)。默认无实现;接入第三方 TTS / 平台 API(iOS VoiceOver、
|
||
/// Android TalkBack、Steam Overlay TTS)时实现本接口并通过 ServiceLocator 注册。
|
||
/// 设计为 string-only 接口而非富文本,避免不同 TTS 引擎对标签解析不一致。
|
||
/// </summary>
|
||
public interface IScreenReader
|
||
{
|
||
void Speak(string text, ScreenReaderPriority priority = ScreenReaderPriority.Polite);
|
||
void StopAll();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 字幕管理器(架构 10_UIModule §11 无障碍补完)。
|
||
///
|
||
/// 职责:与对话气泡(DialogueUI)解耦,专门承担"叙述性字幕 / 关键提示文本 / 战斗播报"。
|
||
/// 订阅 <see cref="StringEventChannelSO"/>(推荐项目内新建 EVT_SubtitleShow)显示底部居中字幕;
|
||
/// 若 <see cref="ServiceLocator"/> 中存在 <see cref="IScreenReader"/> 实现则同步触发 TTS。
|
||
///
|
||
/// 队列策略:Polite 串行(FIFO);Assertive 立即清空队列并显示。
|
||
/// </summary>
|
||
public class SubtitleManager : MonoBehaviour
|
||
{
|
||
[Header("UI")]
|
||
[SerializeField] private GameObject _root;
|
||
[SerializeField] private TMP_Text _label;
|
||
[SerializeField] private CanvasGroup _canvasGroup;
|
||
|
||
[Header("时长")]
|
||
[Tooltip("每行字幕显示时间(秒);可被 Show(text, durationOverride) 覆盖。")]
|
||
[SerializeField] private float _defaultDuration = 3f;
|
||
[SerializeField] private float _fadeDuration = 0.2f;
|
||
|
||
[Header("事件频道(可选)")]
|
||
[SerializeField] private StringEventChannelSO _onSubtitleShow;
|
||
|
||
private readonly Queue<(string text, float duration, ScreenReaderPriority pri)> _queue = new();
|
||
private Coroutine _playCo;
|
||
private readonly CompositeDisposable _subs = new();
|
||
|
||
private void OnEnable()
|
||
{
|
||
if (_root != null) _root.SetActive(false);
|
||
if (_canvasGroup != null) _canvasGroup.alpha = 0f;
|
||
_onSubtitleShow?.Subscribe(t => Show(t, _defaultDuration, ScreenReaderPriority.Polite)).AddTo(_subs);
|
||
}
|
||
|
||
private void OnDisable() => _subs.Clear();
|
||
|
||
/// <summary>显示一行字幕;自动按优先级排队。</summary>
|
||
public void Show(string text, float duration = -1f,
|
||
ScreenReaderPriority priority = ScreenReaderPriority.Polite)
|
||
{
|
||
if (string.IsNullOrEmpty(text)) return;
|
||
float d = duration > 0f ? duration : _defaultDuration;
|
||
|
||
if (priority == ScreenReaderPriority.Assertive)
|
||
{
|
||
_queue.Clear();
|
||
if (_playCo != null) StopCoroutine(_playCo);
|
||
_playCo = null;
|
||
}
|
||
_queue.Enqueue((text, d, priority));
|
||
|
||
// 同步触发 TTS
|
||
var reader = ServiceLocator.GetOrDefault<IScreenReader>(null);
|
||
reader?.Speak(text, priority);
|
||
|
||
if (_playCo == null) _playCo = StartCoroutine(PlayLoop());
|
||
}
|
||
|
||
private IEnumerator PlayLoop()
|
||
{
|
||
while (_queue.Count > 0)
|
||
{
|
||
var (text, d, _) = _queue.Dequeue();
|
||
yield return ShowOne(text, d);
|
||
}
|
||
_playCo = null;
|
||
}
|
||
|
||
private IEnumerator ShowOne(string text, float duration)
|
||
{
|
||
if (_label != null) _label.text = text;
|
||
if (_root != null) _root.SetActive(true);
|
||
|
||
yield return Fade(0f, 1f, _fadeDuration);
|
||
yield return new WaitForSecondsRealtime(Mathf.Max(0f, duration - _fadeDuration * 2f));
|
||
yield return Fade(1f, 0f, _fadeDuration);
|
||
|
||
if (_root != null) _root.SetActive(false);
|
||
}
|
||
|
||
private IEnumerator Fade(float from, float to, float dur)
|
||
{
|
||
if (_canvasGroup == null || dur <= 0f)
|
||
{
|
||
if (_canvasGroup != null) _canvasGroup.alpha = to;
|
||
yield break;
|
||
}
|
||
float t = 0f;
|
||
while (t < dur)
|
||
{
|
||
t += Time.unscaledDeltaTime;
|
||
_canvasGroup.alpha = Mathf.Lerp(from, to, t / dur);
|
||
yield return null;
|
||
}
|
||
_canvasGroup.alpha = to;
|
||
}
|
||
}
|
||
}
|