Files
zeling_v2/Assets/_Game/Scripts/UI/SubtitleManager.cs
2026-05-25 13:21:41 +08:00

129 lines
4.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 / 平台 APIiOS 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 串行FIFOAssertive 立即清空队列并显示。
/// </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;
}
}
}