feat(huoshan): 接入表达界面序列帧与本地化文字

This commit is contained in:
2026-07-26 13:06:25 +08:00
parent 3c27b0ef03
commit 0aae59262a
21 changed files with 4285 additions and 5 deletions
@@ -32,7 +32,14 @@ namespace AibisDream.FixSystem
// 切换到深度表达相机
yield return CameraKit.Instance.SwitchCamera(CameraEnum.ExpressionDeep);
yield return UIManager.Instance.GetPanel<ScreenTransitionPanel>().FadeOutAsync(0.5f);
yield return TimelineCenter.Instance.PlayTimelineAsync("火山表达模块/火山表达深入");
if (expressionManager.HasScreenPresentation)
{
yield return expressionManager.PlayScreenDeepAndWait();
}
else
{
yield return TimelineCenter.Instance.PlayTimelineAsync("火山表达模块/火山表达深入");
}
BubbleSlotKit.Instance.LoadBubbles(BubbleSlotEnum.Expression);
}
@@ -16,6 +16,7 @@ namespace AibisDream
[SerializeField] private LanguageParticleManager particleManager;
[SerializeField] private GameObject expressionView;
[SerializeField] private LogReleasePresentationController logReleasePresentation;
[SerializeField] private ExpressionScreenPresentationController screenPresentation;
[Header("默认配置")]
[SerializeField] private List<string> defaultAnxietyPhrases = new List<string>
@@ -111,9 +112,15 @@ namespace AibisDream
logReleasePresentation = GetComponent<LogReleasePresentationController>();
}
if (screenPresentation == null)
{
screenPresentation = GetComponent<ExpressionScreenPresentationController>();
}
if (particleManager != null)
{
particleManager.SetPresentationController(logReleasePresentation);
particleManager.SetScreenPresentationController(screenPresentation);
}
}
@@ -123,6 +130,7 @@ namespace AibisDream
/// </summary>
public void OpenView()
{
screenPresentation?.ResetImmediate();
// 先设置所有初始状态再打开
if (particleManager != null)
{
@@ -230,6 +238,16 @@ namespace AibisDream
yield return particleManager.WaitUntilExpressionFlowReady();
}
internal IEnumerator PlayScreenDeepAndWait()
{
if (screenPresentation == null)
yield break;
yield return screenPresentation.PlayDeepAndWait();
}
internal bool HasScreenPresentation => screenPresentation != null;
/// <summary>
/// 从聚焦保持态触发稳定化(概率递增 → 文字锁定 → 排列)
/// </summary>
@@ -256,6 +274,7 @@ namespace AibisDream
/// </summary>
public void CloseView()
{
screenPresentation?.ResetImmediate();
ResetScreenGlitchState();
ApplyScreenGlitchProperties();
@@ -0,0 +1,404 @@
using System.Collections;
using AibisDream.Utility;
using TMPro;
using UnityEngine;
using UnityEngine.Localization.Components;
using UnityEngine.UI;
namespace AibisDream.MiniGame.Language
{
/// <summary>
/// Drives the full-frame Huoshan expression screen while keeping readable text in TMP.
/// The sprite sheet uses the original 384x216 Aseprite timing and the existing world-space
/// Canvas remains the source of truth for interaction and dynamic language particles.
/// </summary>
public sealed class ExpressionScreenPresentationController : MonoBehaviour
{
public enum ScreenState
{
Reset,
Deep,
Panel,
Output,
Floating,
Click,
FocusHidden
}
private const int DeepFirst = 0;
private const int DeepLast = 14;
private const int UiFlashFirst = 15;
private const int UiFlashLast = 29;
private const int StatusRevealFrame = 21;
private const int RingFirst = 30;
private const int RingLast = 40;
private const int OutputFirst = 41;
private const int OutputLast = 47;
private const int OutputTextRevealFrame = 45;
private const int FloatingFrame = 48;
private const int ClickFirst = 49;
private const int ClickLast = 52;
private const string ObjectiveLocalizationKey = "huoshan_expression_objective";
private const string OutputLocalizationKey = "huoshan_expression_output";
[Header("Frame Animation")]
[SerializeField] private SpriteRenderer screenRenderer;
[SerializeField] private Sprite[] frames = new Sprite[53];
[Header("Existing TMP / Interaction")]
[SerializeField] private TMP_Text integrationProgressText;
[SerializeField] private TMP_Text interferenceCountText;
[SerializeField] private Button outputButton;
[SerializeField] private TMP_FontAsset screenFont;
[Header("Objective TMP (384x216 reference)")]
[SerializeField] private Vector2 objectivePosition = new Vector2(-8.18f, -4.08f);
[SerializeField] private Vector2 objectiveSize = new Vector2(7.4f, 0.95f);
[SerializeField] private float objectiveFontSize = 0.28f;
[SerializeField] private Color objectiveColor = new Color(0.78f, 0.91f, 1f, 1f);
private int playbackGeneration;
private TMP_Text objectiveText;
private TMP_Text outputText;
private LocalizeStringEvent objectiveLocalization;
private LocalizeStringEvent outputLocalization;
private Material objectiveMaterial;
private ScreenState state = ScreenState.Reset;
public ScreenState State => state;
private void Awake()
{
ResolveSceneReferences();
ConfigureExistingUiAsTextOnly();
CreateObjectiveText();
BindStaticLocalization();
ResetImmediate();
}
private void ResolveSceneReferences()
{
if (screenRenderer == null)
{
SpriteRenderer[] renderers = GetComponentsInChildren<SpriteRenderer>(true);
foreach (SpriteRenderer candidate in renderers)
{
if (candidate != null && candidate.gameObject.name == "火山释放log界面")
{
screenRenderer = candidate;
break;
}
}
}
if (outputButton != null)
{
outputText = outputButton.GetComponentInChildren<TMP_Text>(true);
}
}
private void ConfigureExistingUiAsTextOnly()
{
MakeParentImageTransparent(integrationProgressText);
MakeParentImageTransparent(interferenceCountText);
if (integrationProgressText != null)
integrationProgressText.raycastTarget = false;
if (interferenceCountText != null)
interferenceCountText.raycastTarget = false;
if (outputButton != null && outputButton.targetGraphic != null)
{
Color color = outputButton.targetGraphic.color;
color.a = 0f;
outputButton.targetGraphic.color = color;
outputButton.targetGraphic.raycastTarget = true;
}
}
private static void MakeParentImageTransparent(TMP_Text text)
{
if (text == null || text.transform.parent == null)
return;
Image image = text.transform.parent.GetComponent<Image>();
if (image == null)
return;
Color color = image.color;
color.a = 0f;
image.color = color;
image.raycastTarget = false;
}
private void CreateObjectiveText()
{
Canvas canvas = integrationProgressText != null
? integrationProgressText.GetComponentInParent<Canvas>()
: GetComponentInChildren<Canvas>(true);
if (canvas == null)
{
Debug.LogError("[ExpressionScreenPresentation] ExpressView 下未找到 World Space Canvas,任务描述无法创建。");
return;
}
Transform existing = canvas.transform.Find("Expression Objective (TMP)");
if (existing != null)
{
objectiveText = existing.GetComponent<TMP_Text>();
return;
}
var objectiveObject = new GameObject(
"Expression Objective (TMP)",
typeof(RectTransform),
typeof(CanvasRenderer),
typeof(TextMeshProUGUI));
objectiveObject.layer = canvas.gameObject.layer;
RectTransform rect = objectiveObject.GetComponent<RectTransform>();
rect.SetParent(canvas.transform, false);
rect.anchorMin = new Vector2(0.5f, 0.5f);
rect.anchorMax = new Vector2(0.5f, 0.5f);
rect.pivot = new Vector2(0f, 0.5f);
rect.anchoredPosition = objectivePosition;
rect.sizeDelta = objectiveSize;
rect.SetAsFirstSibling();
var text = objectiveObject.GetComponent<TextMeshProUGUI>();
text.font = screenFont;
if (integrationProgressText != null && integrationProgressText.fontSharedMaterial != null)
{
objectiveMaterial = new Material(integrationProgressText.fontSharedMaterial)
{
name = "Huoshan Expression Objective TMP (Runtime)"
};
text.fontSharedMaterial = objectiveMaterial;
}
text.fontSize = objectiveFontSize;
text.fontStyle = FontStyles.Bold;
text.color = objectiveColor;
text.alignment = TextAlignmentOptions.Left;
text.enableWordWrapping = true;
text.overflowMode = TextOverflowModes.Truncate;
text.raycastTarget = false;
objectiveText = text;
}
private void BindStaticLocalization()
{
objectiveLocalization = BindLocalizedText(objectiveText, ObjectiveLocalizationKey);
outputLocalization = BindLocalizedText(outputText, OutputLocalizationKey);
}
private static LocalizeStringEvent BindLocalizedText(TMP_Text text, string entryKey)
{
if (text == null)
return null;
LocalizeStringEvent localize = text.GetComponent<LocalizeStringEvent>();
if (localize == null)
localize = text.gameObject.AddComponent<LocalizeStringEvent>();
localize.StringReference.SetReference(ConstRef.UITextTable, entryKey);
localize.OnUpdateString.RemoveAllListeners();
localize.OnUpdateString.AddListener(value => text.text = value);
localize.StringReference.RefreshString();
return localize;
}
public IEnumerator PlayDeepAndWait()
{
int generation = BeginPlayback();
SetOverlayVisibility(false, false, false);
yield return PlayRangeAndWait(DeepFirst, DeepLast, generation);
if (generation != playbackGeneration)
yield break;
state = ScreenState.Deep;
}
public IEnumerator PlayPanelAndWait()
{
int generation = BeginPlayback();
yield return PlayRangeAndWait(UiFlashFirst, UiFlashLast, generation, StatusRevealFrame, ShowStatusAndObjective);
if (generation != playbackGeneration)
yield break;
yield return PlayRangeAndWait(RingFirst, RingLast, generation);
if (generation != playbackGeneration)
yield break;
ShowStatusAndObjective();
state = ScreenState.Panel;
}
public IEnumerator PlayOutputAndWait()
{
int generation = BeginPlayback();
SetOutputTextVisible(false);
yield return PlayRangeAndWait(
OutputFirst,
OutputLast,
generation,
OutputTextRevealFrame,
() => SetOutputTextVisible(true));
if (generation != playbackGeneration)
yield break;
SetFrame(FloatingFrame);
SetOutputTextVisible(true);
state = ScreenState.Floating;
}
public IEnumerator PlayClickAndWait()
{
int generation = BeginPlayback();
SetOutputTextVisible(true);
yield return PlayRangeAndWait(ClickFirst, ClickLast, generation);
if (generation != playbackGeneration)
yield break;
state = ScreenState.Click;
}
public void PrepareForNewRound()
{
StopPlayback();
SetFrame(DeepFirst);
SetOverlayVisibility(false, false, false);
state = ScreenState.Reset;
}
public void HideOverlayForFocus()
{
SetOverlayVisibility(false, false, false);
state = ScreenState.FocusHidden;
}
public void ResetImmediate()
{
StopPlayback();
SetFrame(DeepFirst);
SetOverlayVisibility(false, false, false);
if (outputButton != null)
{
outputButton.interactable = false;
outputButton.gameObject.SetActive(false);
}
state = ScreenState.Reset;
}
private int BeginPlayback()
{
StopPlayback();
return playbackGeneration;
}
private IEnumerator PlayRangeAndWait(
int firstFrame,
int lastFrame,
int generation,
int revealFrame = -1,
System.Action revealAction = null)
{
for (int frameIndex = firstFrame; frameIndex <= lastFrame; frameIndex++)
{
if (generation != playbackGeneration)
yield break;
SetFrame(frameIndex);
if (frameIndex == revealFrame)
revealAction?.Invoke();
yield return new WaitForSeconds(GetFrameDuration(frameIndex));
}
}
private void SetFrame(int frameIndex)
{
if (screenRenderer == null || frames == null ||
frameIndex < 0 || frameIndex >= frames.Length || frames[frameIndex] == null)
{
return;
}
screenRenderer.gameObject.SetActive(true);
screenRenderer.enabled = true;
screenRenderer.sprite = frames[frameIndex];
}
private static float GetFrameDuration(int frameIndex)
{
switch (frameIndex)
{
case 0:
case 29:
case 47:
case 48:
return 0.3f;
case 16:
case 17:
case 21:
case 25:
case 49:
case 50:
case 51:
case 52:
return 0.05f;
default:
return 0.1f;
}
}
private void ShowStatusAndObjective()
{
SetOverlayVisibility(true, true, false);
}
private void SetOverlayVisibility(bool statusVisible, bool objectiveVisible, bool outputVisible)
{
SetStatusTextVisible(integrationProgressText, statusVisible);
SetStatusTextVisible(interferenceCountText, statusVisible);
if (objectiveText != null)
objectiveText.gameObject.SetActive(objectiveVisible);
SetOutputTextVisible(outputVisible);
}
private static void SetStatusTextVisible(TMP_Text text, bool visible)
{
if (text == null)
return;
if (visible)
{
var canvasGroup = text.GetComponentInParent<CanvasGroup>();
if (canvasGroup != null)
canvasGroup.alpha = 1f;
}
if (text.transform.parent != null)
text.transform.parent.gameObject.SetActive(visible);
text.gameObject.SetActive(visible);
}
private void SetOutputTextVisible(bool visible)
{
if (outputText != null)
outputText.gameObject.SetActive(visible);
}
private void StopPlayback()
{
playbackGeneration++;
}
private void OnDestroy()
{
if (objectiveLocalization != null)
objectiveLocalization.OnUpdateString.RemoveAllListeners();
if (outputLocalization != null)
outputLocalization.OnUpdateString.RemoveAllListeners();
if (objectiveMaterial != null)
Destroy(objectiveMaterial);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5e1287ca2a7d4d2d90c1a668c0f5bb82
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -5,7 +5,10 @@ using System.Linq;
using DG.Tweening;
using TMPro;
using UnityEngine.UI;
using UnityEngine.Localization;
using UnityEngine.Localization.Settings;
using AibisDream;
using AibisDream.Utility;
namespace AibisDream.MiniGame.Language
{
@@ -134,6 +137,7 @@ namespace AibisDream.MiniGame.Language
[SerializeField] private Button focusSequenceButton;
[Tooltip("火山释放log界面,触发 focus 时对其 SpriteRenderer 做淡出,重新开始时恢复")]
[SerializeField] private SpriteRenderer releaseLogSpriteRenderer;
[SerializeField] private ExpressionScreenPresentationController screenPresentation;
[Tooltip("开场表达panel Timeline 名称(DirectorName 或 DirectorName/AddressableKey),播完才算表达流程完成")]
[SerializeField] private string expressionPanelTimelineName = "火山表达模块/火山表达panel";
[Tooltip("火山表达确认 Timeline 名称,播完才解锁按钮交互")]
@@ -250,6 +254,11 @@ namespace AibisDream.MiniGame.Language
private float lieEdgeJitter;
private float truthGatherProgress;
private LogReleasePresentationController presentationController;
private const string IntegrationLocalizationKey = "huoshan_expression_integration";
private const string InterferenceLocalizationKey = "huoshan_expression_interference";
private string integrationStatusFormat = "{0}% 语言整合完成度";
private string interferenceStatusFormat = "{0} 处异常思绪仍在干扰表达";
private Coroutine statusLocalizationRoutine;
private const string LieCompressionTweenId = "HuoshanLieEdgeCompression";
private const string TruthGatherTweenId = "HuoshanTruthGather";
private const string FocusInterferenceTweenId = "HuoshanFocusInterference";
@@ -286,9 +295,51 @@ namespace AibisDream.MiniGame.Language
private void Start()
{
ResolveScreenPresentation();
LocalizationSettings.SelectedLocaleChanged += HandleLocaleChanged;
statusLocalizationRoutine = StartCoroutine(RefreshStatusLocalization());
// Start 中不再自动初始化,等待外部调用 InitializeSystem
}
private void ResolveScreenPresentation()
{
if (screenPresentation == null)
screenPresentation = GetComponentInParent<ExpressionScreenPresentationController>(true);
}
private void HandleLocaleChanged(Locale locale)
{
if (!isActiveAndEnabled)
return;
if (statusLocalizationRoutine != null)
StopCoroutine(statusLocalizationRoutine);
statusLocalizationRoutine = StartCoroutine(RefreshStatusLocalization());
}
private IEnumerator RefreshStatusLocalization()
{
yield return LocalizationSettings.InitializationOperation;
var integrationOperation = LocalizationSettings.StringDatabase.GetLocalizedStringAsync(
ConstRef.UITextTable,
IntegrationLocalizationKey);
yield return integrationOperation;
if (!string.IsNullOrEmpty(integrationOperation.Result))
integrationStatusFormat = integrationOperation.Result;
var interferenceOperation = LocalizationSettings.StringDatabase.GetLocalizedStringAsync(
ConstRef.UITextTable,
InterferenceLocalizationKey);
yield return interferenceOperation;
if (!string.IsNullOrEmpty(interferenceOperation.Result))
interferenceStatusFormat = interferenceOperation.Result;
statusLocalizationRoutine = null;
if (isInitialized)
UpdateStatusUI();
}
/// <summary>
/// 初始化系统(可以由外部调用,用于配置游戏参数)
/// </summary>
@@ -373,6 +424,8 @@ namespace AibisDream.MiniGame.Language
if (!isInitialized)
yield break;
ResolveScreenPresentation();
screenPresentation?.PrepareForNewRound();
StopFocusTransitionRoutine();
KillCompletionUiTweens();
resolveTotalDurationOverride = -1f;
@@ -1286,7 +1339,15 @@ namespace AibisDream.MiniGame.Language
private IEnumerator PlayConfirmTimelineAndUnlockButton()
{
if (!string.IsNullOrEmpty(expressionConfirmTimelineName) && TimelineCenter.Instance != null)
ResolveScreenPresentation();
if (screenPresentation != null)
{
yield return screenPresentation.PlayOutputAndWait();
if (screenPresentation == null ||
screenPresentation.State != ExpressionScreenPresentationController.ScreenState.Floating)
yield break;
}
else if (!string.IsNullOrEmpty(expressionConfirmTimelineName) && TimelineCenter.Instance != null)
{
yield return TimelineCenter.Instance.PlayTimelineAsync(expressionConfirmTimelineName);
}
@@ -1303,6 +1364,29 @@ namespace AibisDream.MiniGame.Language
if (completionPhase != CompletionPhase.Completed)
return;
if (focusSequenceButton != null)
focusSequenceButton.interactable = false;
ResolveScreenPresentation();
if (screenPresentation != null)
{
StartCoroutine(PlayClickThenBeginFocus());
return;
}
BeginFocusSequence();
}
private IEnumerator PlayClickThenBeginFocus()
{
yield return screenPresentation.PlayClickAndWait();
if (screenPresentation == null ||
screenPresentation.State != ExpressionScreenPresentationController.ScreenState.Click)
{
yield break;
}
screenPresentation.HideOverlayForFocus();
BeginFocusSequence();
}
@@ -1837,6 +1921,11 @@ namespace AibisDream.MiniGame.Language
presentationController = controller;
}
public void SetScreenPresentationController(ExpressionScreenPresentationController controller)
{
screenPresentation = controller;
}
public void BeginLieEdgeCompression(float duration, float jitterAmount)
{
if (completionPhase != CompletionPhase.FocusHolding)
@@ -3153,7 +3242,20 @@ namespace AibisDream.MiniGame.Language
/// </summary>
private IEnumerator PlayExpressionPanelThenReady()
{
if (!string.IsNullOrEmpty(expressionPanelTimelineName) && TimelineCenter.Instance != null)
ResolveScreenPresentation();
if (screenPresentation != null)
{
if (languageDeepPanel1 != null)
languageDeepPanel1.SetActive(true);
if (languageDeepPanel2 != null)
languageDeepPanel2.SetActive(true);
yield return screenPresentation.PlayPanelAndWait();
if (screenPresentation == null ||
screenPresentation.State != ExpressionScreenPresentationController.ScreenState.Panel)
yield break;
}
else if (!string.IsNullOrEmpty(expressionPanelTimelineName) && TimelineCenter.Instance != null)
{
// 火山表达 panel timeline 驱动 panel1/2,播放前需先激活(若之前被 fade 过,需重置 alpha)
if (languageDeepPanel1 != null)
@@ -3301,13 +3403,13 @@ namespace AibisDream.MiniGame.Language
{
float ratio = CalculateIntegrationRatio();
int percent = Mathf.RoundToInt(ratio * 100f);
integrationProgressText.text = $"{percent}% 语言整合完成度";
integrationProgressText.text = string.Format(integrationStatusFormat, percent);
}
if (interferenceCountText != null)
{
int interferenceCount = CountInterferenceNodes();
interferenceCountText.text = $"{interferenceCount} 处异常思绪仍在干扰表达";
interferenceCountText.text = string.Format(interferenceStatusFormat, interferenceCount);
}
}
@@ -3409,6 +3511,11 @@ namespace AibisDream.MiniGame.Language
}
}
private void OnDestroy()
{
LocalizationSettings.SelectedLocaleChanged -= HandleLocaleChanged;
}
#if UNITY_EDITOR
private void OnDrawGizmosSelected()
{