添加对话播放速度设置,调整对话以打字机的形式显示

This commit is contained in:
SepComet 2026-02-09 13:31:24 +08:00
parent 26b69bc870
commit 74ac8f199d
8 changed files with 272 additions and 52 deletions

View File

@ -2,7 +2,7 @@
# Id SpeakerId Expression SpeakerName Direction Text Emphasis ChapterId DialogId
# int string ExpressionType string int string EmphasisType int int
# 对话行编号 策划备注 说话人Id 表情 显示人名 说话朝向 说话内容 演出效果 章节Id 对话Id
100100001 Id规则为 Null None Null 0 相传。 None 1.00100001 1001.00001
100100001 Id规则为 Null None Null 0 相传。相传。相传。相传。相传。相传。相传。相传。相传。相传。相传。相传。相传。相传。相传。相传。相传。相传。相传。相传。相传。相传。相传。相传。相传。相传。相传。相传。相传。相传。相传。相传。相传。相传。 None 1.00100001 1001.00001
100100002 第1位数为章节Id Null None Null 0 Mask。 None 1.00100002 1001.00002
100100003 第2-4位数为对话Id Null None Null 0 很好。 None 1.00100003 1001.00003
100200001 第5-9位数为对话行Id Xu Normal 徐晟壹 0 你好,王。 None 1.00200001 1002.00001

View File

@ -15,6 +15,8 @@ namespace CustomComponent
{
#region Property
[SerializeField] private float _playingSpeed = 1.0f;
private const int DialogChapterDivisor = 1000;
private const int LineChapterDivisor = 100000000;
private const int LineDialogDivisor = 100000;
@ -35,6 +37,8 @@ namespace CustomComponent
private bool _isInitialized;
private bool _isPlaying;
public float PlayingSpeed => _playingSpeed;
public bool IsInitialized => _isInitialized;
public bool IsPlaying => _isPlaying;
@ -211,6 +215,7 @@ namespace CustomComponent
_formContext.DialogId = dialogRow.Id;
_formContext.DialogTitle = dialogRow.Title;
_formContext.DialogUIMode = dialogRow.UIMode;
_formContext.PlayingSpeed = Mathf.Max(0f, _playingSpeed);
_currentLineIndex = 0;
ApplyLineToContext(dialogLines[_currentLineIndex], _currentLineIndex, dialogLines.Count);
@ -350,6 +355,7 @@ namespace CustomComponent
_formContext.Direction = lineRow.Direction;
_formContext.Text = lineRow.Text;
_formContext.Emphasis = lineRow.Emphasis;
_formContext.PlayingSpeed = Mathf.Max(0f, _playingSpeed);
_formContext.LineIndex = lineIndex;
_formContext.TotalLines = totalLines;
_formContext.IsLastLine = lineIndex >= totalLines - 1;
@ -404,4 +410,4 @@ namespace CustomComponent
#endregion
}
}
}

View File

@ -4,6 +4,8 @@ namespace UI
{
public class DialogFormContext : UIContext
{
public float PlayingSpeed = 1f;
public int ChapterId = 0;
public int DialogId = 0;
public string DialogTitle = string.Empty;

View File

@ -1,4 +1,5 @@
using Definition.Enum;
using DG.Tweening;
using Definition.Enum;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
@ -10,6 +11,8 @@ namespace UI
{
public override DialogFormMode UIMode => DialogFormMode.BottomBox;
[SerializeField] private GameObject _speakerArea;
[SerializeField] private TMP_Text _speakerNameText;
[SerializeField] private TMP_Text _contentText;
@ -22,10 +25,15 @@ namespace UI
[SerializeField] private int _rightSpritePosition = -450;
[SerializeField] private float _moveDuration = 0.25f;
[SerializeField] private Ease _moveEase = Ease.OutCubic;
private readonly int _singleSpeakerCenterPosition = Screen.width / 2;
private string _leftSpeakerToken = string.Empty;
private string _rightSpeakerToken = string.Empty;
private Sequence _layoutSequence;
public override void StartDialog(DialogFormContext context)
{
@ -37,10 +45,11 @@ namespace UI
_context = context;
string speakerName = NormalizeValue(context.SpeakerName);
if (string.IsNullOrEmpty(speakerName))
string speakerName = context.SpeakerName;
if (_speakerArea != null)
{
speakerName = NormalizeValue(context.SpeakerId);
_speakerArea.SetActive(!string.IsNullOrEmpty(speakerName));
}
if (_speakerNameText != null)
@ -48,15 +57,12 @@ namespace UI
_speakerNameText.text = speakerName;
}
if (_contentText != null)
{
_contentText.text = NormalizeValue(context.Text);
}
PlayTypewriter(_contentText, context.Text, context.PlayingSpeed);
if (string.IsNullOrEmpty(speakerName))
{
ClearSpeakerState();
ApplySpeakerLayout(false, false);
ApplySpeakerLayout(false, false, true);
return;
}
@ -72,13 +78,14 @@ namespace UI
bool hasLeftSpeaker = !string.IsNullOrEmpty(_leftSpeakerToken);
bool hasRightSpeaker = !string.IsNullOrEmpty(_rightSpeakerToken);
ApplySpeakerLayout(hasLeftSpeaker, hasRightSpeaker);
ApplySpeakerLayout(hasLeftSpeaker, hasRightSpeaker, false);
}
protected override void OnClose(bool isShutdown, object userData)
{
ClearSpeakerState();
ApplySpeakerLayout(false, false);
KillLayoutTween();
ApplySpeakerLayout(false, false, true);
base.OnClose(isShutdown, userData);
}
@ -88,35 +95,164 @@ namespace UI
_rightSpeakerToken = string.Empty;
}
private void ApplySpeakerLayout(bool hasLeftSpeaker, bool hasRightSpeaker)
private void KillLayoutTween()
{
if (_layoutSequence != null)
{
_layoutSequence.Kill();
_layoutSequence = null;
}
}
private void ApplySpeakerLayout(bool hasLeftSpeaker, bool hasRightSpeaker, bool instant)
{
if (_leftSprite == null || _rightSprite == null)
{
return;
}
bool leftCurrentlyVisible = _leftSprite.gameObject.activeSelf;
bool rightCurrentlyVisible = _rightSprite.gameObject.activeSelf;
bool leftTargetVisible = hasLeftSpeaker;
bool rightTargetVisible = hasRightSpeaker;
float leftTargetX = GetTargetX(true, hasLeftSpeaker, hasRightSpeaker);
float rightTargetX = GetTargetX(false, hasLeftSpeaker, hasRightSpeaker);
KillLayoutTween();
PrepareStartState(
leftCurrentlyVisible,
rightCurrentlyVisible,
leftTargetVisible,
rightTargetVisible,
hasLeftSpeaker,
hasRightSpeaker);
if (instant || _moveDuration <= 0f)
{
SetSpritePosition(_leftSprite.rectTransform, leftTargetX);
SetSpritePosition(_rightSprite.rectTransform, rightTargetX);
SetSpriteVisible(_leftSprite, leftTargetVisible);
SetSpriteVisible(_rightSprite, rightTargetVisible);
return;
}
_layoutSequence = DOTween.Sequence();
Tween leftTween = CreateMoveTween(_leftSprite.rectTransform, leftTargetX);
if (leftTween != null)
{
_layoutSequence.Join(leftTween);
}
Tween rightTween = CreateMoveTween(_rightSprite.rectTransform, rightTargetX);
if (rightTween != null)
{
_layoutSequence.Join(rightTween);
}
if (_layoutSequence.active && _layoutSequence.Duration(false) > 0f)
{
_layoutSequence.OnComplete(() =>
{
SetSpriteVisible(_leftSprite, leftTargetVisible);
SetSpriteVisible(_rightSprite, rightTargetVisible);
_layoutSequence = null;
});
}
else
{
SetSpritePosition(_leftSprite.rectTransform, leftTargetX);
SetSpritePosition(_rightSprite.rectTransform, rightTargetX);
SetSpriteVisible(_leftSprite, leftTargetVisible);
SetSpriteVisible(_rightSprite, rightTargetVisible);
_layoutSequence.Kill();
_layoutSequence = null;
}
}
private void PrepareStartState(
bool leftCurrentlyVisible,
bool rightCurrentlyVisible,
bool leftTargetVisible,
bool rightTargetVisible,
bool hasLeftSpeaker,
bool hasRightSpeaker)
{
if (leftTargetVisible && !leftCurrentlyVisible)
{
float leftStartX = GetAppearStartX(true, rightCurrentlyVisible, hasLeftSpeaker, hasRightSpeaker);
SetSpritePosition(_leftSprite.rectTransform, leftStartX);
SetSpriteVisible(_leftSprite, true);
}
if (rightTargetVisible && !rightCurrentlyVisible)
{
float rightStartX = GetAppearStartX(false, leftCurrentlyVisible, hasLeftSpeaker, hasRightSpeaker);
SetSpritePosition(_rightSprite.rectTransform, rightStartX);
SetSpriteVisible(_rightSprite, true);
}
if (leftCurrentlyVisible && !leftTargetVisible)
{
SetSpriteVisible(_leftSprite, true);
}
if (rightCurrentlyVisible && !rightTargetVisible)
{
SetSpriteVisible(_rightSprite, true);
}
}
private float GetAppearStartX(bool isLeft, bool otherCurrentlyVisible, bool hasLeftSpeaker,
bool hasRightSpeaker)
{
if (hasLeftSpeaker && hasRightSpeaker)
{
SetSpriteVisible(_leftSprite, true);
SetSpriteVisible(_rightSprite, true);
SetSpritePosition(_leftSprite.rectTransform, _leftSpritePosition);
SetSpritePosition(_rightSprite.rectTransform, _rightSpritePosition);
return;
// single -> multi: hidden side starts from center, then both move to side positions.
if (otherCurrentlyVisible)
{
return _singleSpeakerCenterPosition;
}
return isLeft ? _leftSpritePosition : _rightSpritePosition;
}
if (hasLeftSpeaker)
// single appears: active side starts from its side and moves to center.
return isLeft ? _leftSpritePosition : _rightSpritePosition;
}
private float GetTargetX(bool isLeft, bool hasLeftSpeaker, bool hasRightSpeaker)
{
if (hasLeftSpeaker && hasRightSpeaker)
{
SetSpriteVisible(_leftSprite, true);
SetSpriteVisible(_rightSprite, false);
SetSpritePosition(_leftSprite.rectTransform, _singleSpeakerCenterPosition);
return;
return isLeft ? _leftSpritePosition : _rightSpritePosition;
}
if (hasRightSpeaker)
if (hasLeftSpeaker || hasRightSpeaker)
{
SetSpriteVisible(_leftSprite, false);
SetSpriteVisible(_rightSprite, true);
SetSpritePosition(_rightSprite.rectTransform, -_singleSpeakerCenterPosition);
return;
// multi -> single: both move to center first, then inactive side hides.
return _singleSpeakerCenterPosition;
}
SetSpriteVisible(_leftSprite, false);
SetSpriteVisible(_rightSprite, false);
return isLeft ? _leftSpritePosition : _rightSpritePosition;
}
private Tween CreateMoveTween(RectTransform rectTransform, float targetX)
{
if (rectTransform == null)
{
return null;
}
if (Mathf.Abs(rectTransform.anchoredPosition.x - targetX) < 0.01f)
{
return null;
}
return rectTransform.DOAnchorPosX(targetX, _moveDuration).SetEase(_moveEase);
}
private static void SetSpriteVisible(Image spriteImage, bool visible)
@ -141,4 +277,4 @@ namespace UI
rectTransform.anchoredPosition = anchoredPosition;
}
}
}
}

View File

@ -1,14 +1,17 @@
using System.Collections;
using Definition.Enum;
using Event;
using TMPro;
using UnityEngine;
namespace UI
{
public abstract class DialogFormBase : UGuiForm
{
[SerializeField] protected float _playSpeed;
protected DialogFormContext _context;
private Coroutine _typingCoroutine;
private TMP_Text _typingTargetText;
private bool _isTypewriting;
public abstract DialogFormMode UIMode { get; }
@ -29,12 +32,18 @@ namespace UI
protected override void OnClose(bool isShutdown, object userData)
{
StopTypewriter();
_context = null;
base.OnClose(isShutdown, userData);
}
public void OnClickNextLine()
{
if (CompleteTypewriterIfRunning())
{
return;
}
GameEntry.Event.Fire(this, DialogNextLineRequestEventArgs.Create());
}
@ -48,24 +57,90 @@ namespace UI
GameEntry.Event.Fire(this, DialogStopRequestEventArgs.Create());
}
protected static string NormalizeValue(string value)
protected void PlayTypewriter(TMP_Text targetText, string text, float charsPerSecond)
{
if (string.IsNullOrEmpty(value))
StopTypewriter();
if (targetText == null)
{
return string.Empty;
return;
}
if (string.Equals(value, "Null", System.StringComparison.OrdinalIgnoreCase))
string finalText = text ?? string.Empty;
_typingTargetText = targetText;
if (charsPerSecond <= 0f || string.IsNullOrEmpty(finalText))
{
return string.Empty;
targetText.text = finalText;
targetText.maxVisibleCharacters = int.MaxValue;
_isTypewriting = false;
return;
}
if (string.Equals(value, "None", System.StringComparison.OrdinalIgnoreCase))
_isTypewriting = true;
_typingCoroutine = StartCoroutine(TypewriterRoutine(targetText, finalText, charsPerSecond));
}
protected void StopTypewriter()
{
if (_typingCoroutine != null)
{
return string.Empty;
StopCoroutine(_typingCoroutine);
_typingCoroutine = null;
}
return value;
_typingTargetText = null;
_isTypewriting = false;
}
private bool CompleteTypewriterIfRunning()
{
if (!_isTypewriting || _typingTargetText == null)
{
return false;
}
_typingTargetText.maxVisibleCharacters = int.MaxValue;
StopTypewriter();
return true;
}
private IEnumerator TypewriterRoutine(TMP_Text targetText, string finalText, float charsPerSecond)
{
targetText.text = finalText;
targetText.ForceMeshUpdate();
int totalCharacters = targetText.textInfo.characterCount;
if (totalCharacters <= 0)
{
targetText.maxVisibleCharacters = int.MaxValue;
_typingCoroutine = null;
_typingTargetText = null;
_isTypewriting = false;
yield break;
}
targetText.maxVisibleCharacters = 0;
float elapsed = 0f;
int visibleCharacters = 0;
while (visibleCharacters < totalCharacters)
{
elapsed += Time.unscaledDeltaTime;
int nextVisible = Mathf.Min(totalCharacters, Mathf.FloorToInt(elapsed * charsPerSecond));
if (nextVisible != visibleCharacters)
{
visibleCharacters = nextVisible;
targetText.maxVisibleCharacters = visibleCharacters;
}
yield return null;
}
targetText.maxVisibleCharacters = int.MaxValue;
_typingCoroutine = null;
_typingTargetText = null;
_isTypewriting = false;
}
}
}

View File

@ -9,11 +9,11 @@ namespace UI
public class MaskDialogForm : DialogFormBase
{
public override DialogFormMode UIMode => DialogFormMode.Mask;
[SerializeField] private Image _maskImage;
[SerializeField] private TMP_Text _text;
public override void StartDialog(DialogFormContext context)
{
if (context == null)
@ -29,10 +29,7 @@ namespace UI
_maskImage.gameObject.SetActive(true);
}
if (_text != null)
{
_text.text = NormalizeValue(context.Text);
}
PlayTypewriter(_text, context.Text, context.PlayingSpeed);
}
}
}

View File

@ -116,7 +116,7 @@ MonoBehaviour:
m_SelectedTrigger: Selected
m_DisabledTrigger: Disabled
m_Interactable: 1
m_TargetGraphic: {fileID: 0}
m_TargetGraphic: {fileID: 3708131469420921886}
m_OnClick:
m_PersistentCalls:
m_Calls:
@ -321,7 +321,7 @@ GameObject:
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
m_IsActive: 0
--- !u!224 &4594737505273760298
RectTransform:
m_ObjectHideFlags: 0
@ -679,12 +679,15 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
_playSpeed: 0
_speakerArea: {fileID: 3736358320082617150}
_speakerNameText: {fileID: 2470970474825277305}
_contentText: {fileID: 6431296888118130931}
_leftSprite: {fileID: 7945103967507868302}
_rightSprite: {fileID: 5385698520020721016}
_leftSpritePosition: 450
_rightSpritePosition: -450
_moveDuration: 0.25
_moveEase: 9
--- !u!1 &7381337123922490182
GameObject:
m_ObjectHideFlags: 0
@ -837,7 +840,7 @@ GameObject:
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
m_IsActive: 0
--- !u!224 &1023330169278438415
RectTransform:
m_ObjectHideFlags: 0

View File

@ -841,6 +841,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: d6174838c30e460429e5628757bbf015, type: 3}
m_Name:
m_EditorClassIdentifier:
_playingSpeed: 10
--- !u!1 &513208572
GameObject:
m_ObjectHideFlags: 0