Danro-Jump/Assets/Scripts/Runtime/UI/MainMenu/ServiceTextInputModalWindow.cs
Danil Iarosh 839a68aef1 Improve VFX, UI overlays, session statistics, COM reliability, audio mixer, and expand architecture guides
Подробный список изменений:
- Перенесены эффекты частиц из C#-кода в готовые префабы-ассеты (Vfx Prefabs) в папке Assets/Resources/Vfx/ с автоматической генерацией материалов в Editor.
- Исправлены ошибки duration на играющих системах частиц и розовые текстуры в GameVfxService.cs.
- Исправлена навигация и фокус кнопок на оверлеях ContinueGameOverlay и PrizeResultOverlay с автоматическим подсвечиванием кнопок лимонно-зеленым цветом при фокусе и проигрыванием звука.
- Добавлена проверка настроек в GameplayHudController.cs для скрытия ContinueGameOverlay, если продолжение игры отключено.
- Расширен сбор статистики: создана структура GameplaySessionRecord, добавлена история до 50 раундов в PrizeSessionStateService и JsonPrizeSessionStateRepository, выведен расчет средних показателей и список 5 последних раундов в сервисной статистике.
- Повышена надежность COM-транспорта: добавлен sleep (80мс) в Dispose для graceful shutdown потоков, реализован цикл переподключения до 10 попыток при обрыве связи.
- Интегрированы настройки громкости с микшером: изменен шаг изменения громкости на 5% (0.05) для удобства, добавлен статус-чек и логи в ApplyServiceVolumes, расширены юнит-тесты на GetFloat параметры.
- Созданы новые архитектурные гайды по аппаратному COM-транспорту и призовой экономике автомата в папке GUIDES/architecture/.
- Оптимизирован и обновлен RAG-индекс проекта.
2026-07-03 15:04:08 +03:00

461 lines
14 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;
using System.Collections.Generic;
using DanroJump.Settings;
using DanroJump.UI.ArcadeInput;
using DanroJump.UI.Modals;
using UnityEngine;
using UnityEngine.UIElements;
namespace DanroJump.UI.MainMenu
{
/// <summary>
/// Модальное окно посимвольного ввода для автомата с джойстиком и одной кнопкой.
/// </summary>
public sealed class ServiceTextInputModalWindow : ModalWindowBase, IDisposable
{
private const string ActiveSlotClass = "service-text-input-slot--active";
private const string ActiveActionClass = "service-text-input-action--active";
private const float NavigationMoveDebounceSeconds = 0.36f;
private const float NavigationKeyDebounceSeconds = 0.16f;
private const float NavigationSubmitDebounceSeconds = 0.22f;
private readonly Label titleLabel;
private readonly Label settingLabel;
private readonly Label previewLabel;
private readonly Label symbolLabel;
private readonly Label statusLabel;
private readonly VisualElement slotsContainer;
private readonly Button confirmButton;
private readonly Button deleteButton;
private readonly Button cancelButton;
private readonly List<Label> slotLabels = new();
private ArcadeStringInputState state;
private Action<string> confirmed;
private NavigationMoveEvent.Direction lastDirectionalStep;
private int lastDirectionalStepFrame = -1;
private float lastDirectionalStepBlockedUntil = float.NegativeInfinity;
private int lastNavigationSubmitFrame = -1;
private float lastNavigationSubmitTime = float.NegativeInfinity;
public ServiceTextInputModalWindow(VisualElement root)
: base(root)
{
if (root == null)
{
return;
}
titleLabel = root.Q<Label>("ServiceTextInputTitle");
settingLabel = root.Q<Label>("ServiceTextInputSetting");
previewLabel = root.Q<Label>("ServiceTextInputPreview");
symbolLabel = root.Q<Label>("ServiceTextInputSymbol");
statusLabel = root.Q<Label>("ServiceTextInputStatus");
slotsContainer = root.Q<VisualElement>("ServiceTextInputSlots");
confirmButton = root.Q<Button>("ServiceTextInputConfirmButton");
deleteButton = root.Q<Button>("ServiceTextInputDeleteButton");
cancelButton = root.Q<Button>("ServiceTextInputCancelButton");
root.RegisterCallback<NavigationSubmitEvent>(HandleNavigationSubmit, TrickleDown.TrickleDown);
if (confirmButton != null)
{
confirmButton.clicked += HandleConfirmButtonClicked;
}
if (deleteButton != null)
{
deleteButton.clicked += HandleDeleteButtonClicked;
}
if (cancelButton != null)
{
cancelButton.clicked += HandleCancelButtonClicked;
}
}
public void SetContext(
string title,
string settingName,
string currentValue,
ArcadeStringInputProfile profile,
Action<string> confirmedCallback)
{
state = new ArcadeStringInputState(profile, currentValue);
confirmed = confirmedCallback;
lastDirectionalStepFrame = -1;
lastDirectionalStepBlockedUntil = float.NegativeInfinity;
lastNavigationSubmitFrame = -1;
lastNavigationSubmitTime = float.NegativeInfinity;
if (titleLabel != null)
{
titleLabel.text = title;
}
if (settingLabel != null)
{
settingLabel.text = settingName;
}
BuildSlotLabels();
RefreshView();
}
public void SetContext(
ServiceSettingDefinition definition,
string currentValue,
ArcadeStringInputProfile profile,
Action<string> confirmedCallback)
{
var settingName = definition != null && !string.IsNullOrWhiteSpace(definition.DisplayName)
? definition.DisplayName
: definition?.Key ?? string.Empty;
SetContext("Ввод значения", settingName, currentValue, profile, confirmedCallback);
}
public override void Open()
{
base.Open();
RefreshView();
}
public void Dispose()
{
if (Root != null)
{
Root.UnregisterCallback<NavigationSubmitEvent>(HandleNavigationSubmit, TrickleDown.TrickleDown);
}
if (confirmButton != null)
{
confirmButton.clicked -= HandleConfirmButtonClicked;
}
if (deleteButton != null)
{
deleteButton.clicked -= HandleDeleteButtonClicked;
}
if (cancelButton != null)
{
cancelButton.clicked -= HandleCancelButtonClicked;
}
}
public override void Close()
{
confirmed = null;
base.Close();
}
public override bool HandleNavigationMove(VisualElement current, NavigationMoveEvent.Direction direction)
{
if (state == null)
{
return false;
}
if (!TryAcceptDirectionalStep(direction, NavigationMoveDebounceSeconds))
{
return true;
}
switch (direction)
{
case NavigationMoveEvent.Direction.Up:
state.MoveUp();
break;
case NavigationMoveEvent.Direction.Down:
state.MoveDown();
break;
case NavigationMoveEvent.Direction.Left:
state.MoveLeft();
break;
case NavigationMoveEvent.Direction.Right:
state.MoveRight();
break;
default:
return false;
}
RefreshView();
return true;
}
public override bool HandleNavigationKey(VisualElement current, KeyCode keyCode)
{
if (state == null)
{
return false;
}
switch (keyCode)
{
case KeyCode.UpArrow:
case KeyCode.W:
if (!TryAcceptDirectionalStep(NavigationMoveEvent.Direction.Up, NavigationKeyDebounceSeconds))
{
return true;
}
state.MoveUp();
break;
case KeyCode.DownArrow:
case KeyCode.S:
if (!TryAcceptDirectionalStep(NavigationMoveEvent.Direction.Down, NavigationKeyDebounceSeconds))
{
return true;
}
state.MoveDown();
break;
case KeyCode.LeftArrow:
case KeyCode.A:
if (!TryAcceptDirectionalStep(NavigationMoveEvent.Direction.Left, NavigationKeyDebounceSeconds))
{
return true;
}
state.MoveLeft();
break;
case KeyCode.RightArrow:
case KeyCode.D:
if (!TryAcceptDirectionalStep(NavigationMoveEvent.Direction.Right, NavigationKeyDebounceSeconds))
{
return true;
}
state.MoveRight();
break;
case KeyCode.Return:
case KeyCode.KeypadEnter:
case KeyCode.Space:
Submit();
return true;
default:
return false;
}
RefreshView();
return true;
}
protected override VisualElement GetDefaultFocusElement()
{
return confirmButton ?? Root;
}
public bool Confirm()
{
if (state == null || confirmed == null)
{
return false;
}
var confirmedCallback = confirmed;
confirmed = null;
RequestClose();
confirmedCallback.Invoke(state.Value);
return true;
}
public void DeleteCurrentCharacter()
{
state?.DeleteCurrentCharacter();
RefreshView();
}
private void HandleNavigationSubmit(NavigationSubmitEvent evt)
{
evt.StopImmediatePropagation();
var now = Time.unscaledTime;
if (Time.frameCount == lastNavigationSubmitFrame ||
now - lastNavigationSubmitTime < NavigationSubmitDebounceSeconds)
{
return;
}
lastNavigationSubmitFrame = Time.frameCount;
lastNavigationSubmitTime = now;
Submit();
}
private void HandleConfirmButtonClicked()
{
if (Time.frameCount == lastNavigationSubmitFrame)
{
return;
}
Confirm();
}
private void HandleDeleteButtonClicked()
{
if (Time.frameCount == lastNavigationSubmitFrame)
{
return;
}
DeleteCurrentCharacter();
}
private void HandleCancelButtonClicked()
{
if (Time.frameCount == lastNavigationSubmitFrame)
{
return;
}
RequestClose();
}
private bool TryAcceptDirectionalStep(NavigationMoveEvent.Direction direction, float debounceSeconds)
{
var now = Time.unscaledTime;
if (direction == lastDirectionalStep &&
(Time.frameCount == lastDirectionalStepFrame ||
now < lastDirectionalStepBlockedUntil))
{
return false;
}
lastDirectionalStep = direction;
lastDirectionalStepFrame = Time.frameCount;
lastDirectionalStepBlockedUntil = now + Mathf.Max(0f, debounceSeconds);
return true;
}
private bool Submit()
{
if (state == null)
{
return false;
}
var result = state.Submit();
switch (result)
{
case ArcadeStringInputSubmitResult.Confirm:
return Confirm();
case ArcadeStringInputSubmitResult.Cancel:
RequestClose();
return true;
default:
RefreshView();
return true;
}
}
private void BuildSlotLabels()
{
slotLabels.Clear();
slotsContainer?.Clear();
if (slotsContainer == null || state == null)
{
return;
}
for (var index = 0; index < state.MaxLength; index++)
{
var label = new Label();
label.AddToClassList("service-text-input-slot");
label.focusable = false;
slotsContainer.Add(label);
slotLabels.Add(label);
}
}
private void RefreshView()
{
if (state == null)
{
return;
}
if (previewLabel != null)
{
previewLabel.text = string.IsNullOrEmpty(state.Value) ? " " : state.Value;
}
for (var index = 0; index < slotLabels.Count; index++)
{
var label = slotLabels[index];
label.text = FormatCharacter(state.GetSlot(index));
if (state.IsSlotSelected(index))
{
label.AddToClassList(ActiveSlotClass);
}
else
{
label.RemoveFromClassList(ActiveSlotClass);
}
}
if (symbolLabel != null)
{
symbolLabel.text = state.SelectionKind == ArcadeStringInputSelectionKind.Character
? FormatCharacter(state.GetSlot(state.SelectedSlot))
: FormatAction(state.SelectedAction);
}
bool isAnyActionSelected = false;
RefreshActionButton(confirmButton, ArcadeStringInputAction.Confirm, ref isAnyActionSelected);
RefreshActionButton(deleteButton, ArcadeStringInputAction.Delete, ref isAnyActionSelected);
RefreshActionButton(cancelButton, ArcadeStringInputAction.Cancel, ref isAnyActionSelected);
if (!isAnyActionSelected && Root != null)
{
Root.Focus();
}
}
private void RefreshActionButton(Button button, ArcadeStringInputAction action, ref bool isAnyActionSelected)
{
if (button == null)
{
return;
}
if (state.IsActionSelected(action))
{
button.AddToClassList(ActiveActionClass);
button.Focus();
isAnyActionSelected = true;
return;
}
button.RemoveFromClassList(ActiveActionClass);
}
private void SetStatus(string status)
{
if (statusLabel != null)
{
statusLabel.text = status;
}
}
private static string FormatCharacter(char character)
{
return character == ArcadeStringInputState.BlankCharacter ? "_" : character.ToString();
}
private static string FormatAction(ArcadeStringInputAction action)
{
return action switch
{
ArcadeStringInputAction.Confirm => "OK",
ArcadeStringInputAction.Delete => "Удалить",
ArcadeStringInputAction.Cancel => "Отмена",
_ => string.Empty
};
}
}
}