← Назад к вопросам

Реализовать туториал для игры

2.0 Middle🔥 111 комментариев
#C# и ООП#UI#Unity Core#Ресурсы и ассеты

Условие

Реализуйте систему обучающих подсказок для игры.

Требования

  1. Последовательность шагов туториала
  2. Подсветка UI элементов
  3. Блокировка неактивных элементов
  4. Указатели и стрелки
  5. Пропуск туториала
  6. Сохранение состояния прохождения

Подход

  • ScriptableObject для шагов
  • TutorialManager синглтон
  • События для перехода между шагами

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Решение: Система туториала для игры

Эта задача требует создания гибкой системы туториала с пошаговым обучением, подсветкой элементов и отслеживанием прогресса. Расскажу о полной архитектуре.

Архитектура системы

Основные компоненты:

  1. TutorialStep — ScriptableObject для описания шага
  2. TutorialSequence — последовательность шагов
  3. TutorialManager — основной менеджер
  4. TutorialUI — интерфейс и визуализация
  5. 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. Пропуск — возможность пропустить туториал

Расширение возможностей

  • Жесты — туториал для мобильных платформ
  • Видео — вставка видео в туториал
  • Условные шаги — шаги в зависимости от выбора игрока
  • Локализация — поддержка нескольких языков
  • Аналитика — отслеживание прохождения туториала

Эта система обеспечивает профессиональный и информативный туториал для игры.

Реализовать туториал для игры | PrepBro