← Назад к вопросам
Реализовать туториал для игры
2.0 Middle🔥 111 комментариев
#C# и ООП#UI#Unity Core#Ресурсы и ассеты
Условие
Реализуйте систему обучающих подсказок для игры.
Требования
- Последовательность шагов туториала
- Подсветка UI элементов
- Блокировка неактивных элементов
- Указатели и стрелки
- Пропуск туториала
- Сохранение состояния прохождения
Подход
- ScriptableObject для шагов
- TutorialManager синглтон
- События для перехода между шагами
Комментарии (1)
🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)
Ответ сгенерирован нейросетью и может содержать ошибки
Решение: Система туториала для игры
Эта задача требует создания гибкой системы туториала с пошаговым обучением, подсветкой элементов и отслеживанием прогресса. Расскажу о полной архитектуре.
Архитектура системы
Основные компоненты:
- TutorialStep — ScriptableObject для описания шага
- TutorialSequence — последовательность шагов
- TutorialManager — основной менеджер
- TutorialUI — интерфейс и визуализация
- TutorialHighlight — подсветка элементов
TutorialStep - Описание шага
public enum TutorialStepType
{
ShowUI, // Показать подсказку с текстом
HighlightUI, // Подсветить элемент UI
BlockInput, // Заблокировать ввод до действия
ShowArrow, // Показать стрелку
WaitForInput, // Ждать конкретного ввода
WaitForEvent // Ждать события
}
[System.Serializable]
public class TutorialHighlightData
{
public RectTransform targetUI;
public Vector3 worldPosition;
public float highlightRadius = 100f;
public bool useWorldSpace = false;
}
[CreateAssetMenu(fileName = "TutorialStep_", menuName = "Game/Tutorial/Step")]
public class TutorialStep : ScriptableObject
{
[SerializeField] public string stepId;
[SerializeField] public TutorialStepType stepType;
[TextArea(2, 5)]
[SerializeField] public string description;
[SerializeField] public Sprite icon;
// Параметры подсветки
[SerializeField] public TutorialHighlightData highlightData;
[SerializeField] public Color highlightColor = new Color(1, 1, 0, 0.3f);
[SerializeField] public float highlightDuration = 0.5f;
// Параметры стрелки
[SerializeField] public Vector3 arrowOffset = Vector3.zero;
[SerializeField] public bool showArrow = false;
// Условия перехода
[SerializeField] public string nextStepId;
[SerializeField] public KeyCode triggerKey = KeyCode.None;
[SerializeField] public string triggerEventName;
[SerializeField] public float autoAdvanceTime = 0f; // 0 = не автоматически
[SerializeField] public bool blockInput = false;
[SerializeField] public bool allowSkip = true;
// Дополнительно
[SerializeField] public bool showBackground = true;
[SerializeField] public bool showBlackout = true;
[SerializeField] public float blurAmount = 0f;
}
TutorialSequence - Последовательность
[CreateAssetMenu(fileName = "TutorialSequence_", menuName = "Game/Tutorial/Sequence")]
public class TutorialSequence : ScriptableObject
{
[SerializeField] public string sequenceId;
[SerializeField] public string sequenceName;
[SerializeField] public List<TutorialStep> steps = new List<TutorialStep>();
[SerializeField] public bool isRequired = true;
[SerializeField] public int minimumPlayerLevel = 0;
[TextArea(2, 4)]
[SerializeField] public string welcomeMessage;
public TutorialStep GetStep(int index)
{
return index >= 0 && index < steps.Count ? steps[index] : null;
}
public TutorialStep GetStepById(string id)
{
return steps.Find(s => s.stepId == id);
}
public int GetStepIndex(string id)
{
return steps.FindIndex(s => s.stepId == id);
}
}
TutorialManager - Основной менеджер
public class TutorialManager : MonoBehaviour
{
public static TutorialManager Instance { get; private set; }
[SerializeField] private TutorialSequence[] availableSequences;
[SerializeField] private TutorialUI tutorialUI;
[SerializeField] private string progressSaveKey = "TutorialProgress";
[SerializeField] private bool autoStartTutorial = true;
private TutorialSequence currentSequence;
private int currentStepIndex = 0;
private TutorialStep currentStep;
private HashSet<string> completedSequences = new HashSet<string>();
private bool isTutorialActive = false;
private Coroutine stepCoroutine;
public event System.Action<TutorialStep> OnStepStart;
public event System.Action<TutorialStep> OnStepComplete;
public event System.Action<TutorialSequence> OnSequenceComplete;
public event System.Action OnTutorialComplete;
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
}
else
{
Instance = this;
DontDestroyOnLoad(gameObject);
}
}
private void Start()
{
LoadProgress();
if (autoStartTutorial)
{
StartFirstTutorial();
}
}
private void StartFirstTutorial()
{
foreach (var sequence in availableSequences)
{
if (!completedSequences.Contains(sequence.sequenceId) && sequence.isRequired)
{
StartTutorialSequence(sequence);
return;
}
}
}
public void StartTutorialSequence(TutorialSequence sequence)
{
if (isTutorialActive)
return;
currentSequence = sequence;
currentStepIndex = 0;
isTutorialActive = true;
tutorialUI.ShowWelcomeMessage(sequence.welcomeMessage);
Invoke(nameof(AdvanceStep), 2f);
}
public void AdvanceStep()
{
if (!isTutorialActive || currentSequence == null)
return;
if (stepCoroutine != null)
StopCoroutine(stepCoroutine);
OnStepComplete?.Invoke(currentStep);
currentStepIndex++;
if (currentStepIndex >= currentSequence.steps.Count)
{
CompleteTutorialSequence();
return;
}
currentStep = currentSequence.GetStep(currentStepIndex);
OnStepStart?.Invoke(currentStep);
stepCoroutine = StartCoroutine(ExecuteStep(currentStep));
}
private IEnumerator ExecuteStep(TutorialStep step)
{
// Скрываем игровой интерфейс если необходимо
if (step.showBlackout)
{
tutorialUI.ShowBlackout(true);
}
// Показываем текст описания
tutorialUI.ShowDescription(step.description, step.icon);
// Подсвечиваем элемент
if (step.highlightData.targetUI != null || step.highlightData.useWorldSpace)
{
tutorialUI.HighlightElement(step.highlightData, step.highlightColor);
}
// Показываем стрелку
if (step.showArrow)
{
tutorialUI.ShowArrow(step.arrowOffset);
}
// Блокируем ввод если необходимо
if (step.blockInput)
{
BlockGameInput(true);
}
// Ждём условия перехода
yield return WaitForStepCompletion(step);
// Очищаем
BlockGameInput(false);
tutorialUI.HideHighlight();
tutorialUI.HideArrow();
tutorialUI.ShowBlackout(false);
// Переходим к следующему шагу
if (step.autoAdvanceTime > 0)
{
yield return new WaitForSeconds(step.autoAdvanceTime);
}
AdvanceStep();
}
private IEnumerator WaitForStepCompletion(TutorialStep step)
{
bool completed = false;
// Способ 1: Нажатие клавиши
if (step.triggerKey != KeyCode.None)
{
while (!Input.GetKeyDown(step.triggerKey))
{
if (Input.GetKeyDown(KeyCode.Escape) && step.allowSkip)
{
SkipTutorial();
yield break;
}
yield return null;
}
completed = true;
}
// Способ 2: Событие
if (!completed && !string.IsNullOrEmpty(step.triggerEventName))
{
System.Action eventHandler = null;
eventHandler = () => {
completed = true;
};
EventSystem.RegisterListener(step.triggerEventName, eventHandler);
while (!completed)
{
if (Input.GetKeyDown(KeyCode.Escape) && step.allowSkip)
{
EventSystem.UnregisterListener(step.triggerEventName, eventHandler);
SkipTutorial();
yield break;
}
yield return null;
}
EventSystem.UnregisterListener(step.triggerEventName, eventHandler);
}
// Способ 3: Автопереход
if (!completed && step.autoAdvanceTime > 0)
{
yield return new WaitForSeconds(step.autoAdvanceTime);
}
}
private void CompleteTutorialSequence()
{
completedSequences.Add(currentSequence.sequenceId);
SaveProgress();
OnSequenceComplete?.Invoke(currentSequence);
isTutorialActive = false;
tutorialUI.ShowCompletionMessage();
// Проверяем есть ли ещё туториалы
foreach (var sequence in availableSequences)
{
if (!completedSequences.Contains(sequence.sequenceId) && sequence.isRequired)
{
Invoke(nameof(StartFirstTutorial), 3f);
return;
}
}
OnTutorialComplete?.Invoke();
}
public void SkipTutorial()
{
if (!isTutorialActive)
return;
if (stepCoroutine != null)
StopCoroutine(stepCoroutine);
completedSequences.Add(currentSequence.sequenceId);
SaveProgress();
isTutorialActive = false;
tutorialUI.HideAll();
BlockGameInput(false);
}
private void BlockGameInput(bool block)
{
Time.timeScale = block ? 0f : 1f;
}
public void SaveProgress()
{
TutorialProgressData data = new TutorialProgressData
{
completedSequences = new List<string>(completedSequences)
};
string json = JsonUtility.ToJson(data);
PlayerPrefs.SetString(progressSaveKey, json);
PlayerPrefs.Save();
}
public void LoadProgress()
{
if (!PlayerPrefs.HasKey(progressSaveKey))
return;
string json = PlayerPrefs.GetString(progressSaveKey);
try
{
TutorialProgressData data = JsonUtility.FromJson<TutorialProgressData>(json);
completedSequences = new HashSet<string>(data.completedSequences);
}
catch (System.Exception e)
{
Debug.LogError($"Failed to load tutorial progress: {e.Message}");
}
}
public bool IsSequenceCompleted(string sequenceId) => completedSequences.Contains(sequenceId);
public bool IsTutorialActive => isTutorialActive;
}
[System.Serializable]
public class TutorialProgressData
{
public List<string> completedSequences = new List<string>();
}
TutorialUI - Интерфейс и визуализация
public class TutorialUI : MonoBehaviour
{
[SerializeField] private CanvasGroup tutorialPanel;
[SerializeField] private Image highlightImage;
[SerializeField] private Image blackoutImage;
[SerializeField] private Text descriptionText;
[SerializeField] private Image iconImage;
[SerializeField] private RectTransform arrowContainer;
[SerializeField] private Image arrowImage;
[SerializeField] private Button skipButton;
[SerializeField] private Button nextButton;
[SerializeField] private Color blackoutColor = new Color(0, 0, 0, 0.7f);
[SerializeField] private AnimationCurve highlightAnimation = AnimationCurve.EaseInOut(0, 0, 1, 1);
private RectTransform highlightRect;
private Coroutine highlightCoroutine;
private Coroutine arrowCoroutine;
private void Start()
{
highlightRect = highlightImage.GetComponent<RectTransform>();
skipButton?.onClick.AddListener(() => TutorialManager.Instance.SkipTutorial());
nextButton?.onClick.AddListener(() => TutorialManager.Instance.AdvanceStep());
HideAll();
}
public void ShowDescription(string text, Sprite icon)
{
tutorialPanel.alpha = 1f;
tutorialPanel.blocksRaycasts = true;
if (descriptionText != null)
{
descriptionText.text = text;
descriptionText.gameObject.SetActive(true);
}
if (iconImage != null && icon != null)
{
iconImage.sprite = icon;
iconImage.gameObject.SetActive(true);
}
}
public void HighlightElement(TutorialHighlightData data, Color color)
{
if (highlightCoroutine != null)
StopCoroutine(highlightCoroutine);
if (data.targetUI != null)
{
highlightRect.position = data.targetUI.position;
highlightRect.sizeDelta = data.targetUI.sizeDelta + Vector2.one * 20f;
highlightImage.color = color;
highlightImage.gameObject.SetActive(true);
}
else if (data.useWorldSpace)
{
// Преобразуем мировые координаты в UI
Vector3 screenPos = Camera.main.WorldToScreenPoint(data.worldPosition);
highlightRect.position = screenPos;
highlightRect.sizeDelta = Vector2.one * (data.highlightRadius * 2f);
highlightImage.color = color;
highlightImage.gameObject.SetActive(true);
}
highlightCoroutine = StartCoroutine(PulseHighlight());
}
private IEnumerator PulseHighlight()
{
float elapsedTime = 0f;
Color originalColor = highlightImage.color;
while (highlightImage.gameObject.activeInHierarchy)
{
elapsedTime += Time.deltaTime;
float t = Mathf.PingPong(elapsedTime, 0.6f) / 0.6f;
Color newColor = originalColor;
newColor.a = Mathf.Lerp(0.2f, 0.5f, t);
highlightImage.color = newColor;
yield return null;
}
}
public void HideHighlight()
{
if (highlightCoroutine != null)
StopCoroutine(highlightCoroutine);
highlightImage.gameObject.SetActive(false);
}
public void ShowArrow(Vector3 offset)
{
arrowImage.gameObject.SetActive(true);
arrowContainer.anchoredPosition = (Vector2)offset;
if (arrowCoroutine != null)
StopCoroutine(arrowCoroutine);
arrowCoroutine = StartCoroutine(AnimateArrow());
}
private IEnumerator AnimateArrow()
{
float elapsedTime = 0f;
while (arrowImage.gameObject.activeInHierarchy)
{
elapsedTime += Time.deltaTime;
float offset = Mathf.Sin(elapsedTime * 3f) * 10f;
arrowContainer.anchoredPosition += Vector2.down * offset * Time.deltaTime;
yield return null;
}
}
public void HideArrow()
{
if (arrowCoroutine != null)
StopCoroutine(arrowCoroutine);
arrowImage.gameObject.SetActive(false);
}
public void ShowBlackout(bool show)
{
blackoutImage.gameObject.SetActive(show);
}
public void ShowWelcomeMessage(string message)
{
descriptionText.text = message;
tutorialPanel.alpha = 1f;
}
public void ShowCompletionMessage()
{
descriptionText.text = "Туториал завершён!";
StartCoroutine(HideAfterDelay(3f));
}
private IEnumerator HideAfterDelay(float delay)
{
yield return new WaitForSeconds(delay);
HideAll();
}
public void HideAll()
{
tutorialPanel.alpha = 0f;
tutorialPanel.blocksRaycasts = false;
HideHighlight();
HideArrow();
ShowBlackout(false);
}
}
Ключевые особенности
1. ScriptableObject — описание шагов в инспекторе
2. Последовательные шаги — управление потоком туториала
3. Подсветка элементов — пульсирующая подсветка UI и мировых объектов
4. Блокировка ввода — pause режим для туториала
5. События — система ожидания действия игрока
6. Сохранение прогресса — запомнение пройденных туториалов
7. Пропуск — возможность пропустить туториал
Расширение возможностей
- Жесты — туториал для мобильных платформ
- Видео — вставка видео в туториал
- Условные шаги — шаги в зависимости от выбора игрока
- Локализация — поддержка нескольких языков
- Аналитика — отслеживание прохождения туториала
Эта система обеспечивает профессиональный и информативный туториал для игры.