using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; using UnityEngine.InputSystem; /// /// Контроллер вступительной кат-сцены «Синтезия» (2D-арты / слайд-шоу). /// Воспроизводит 5 атмосферных иллюстрированных кадров с эффектом Ken Burns (панорамирование/зум), /// плавными растворениями (cross-fade), процедурным звуковым сопровождением и финальным логотипом. /// public class IntroCutsceneController : MonoBehaviour { private static IntroCutsceneController _instance; public static IntroCutsceneController Instance => _instance; [Header("Спрайты кадров")] public Sprite frame1Sprite; public Sprite frame2Sprite; public Sprite frame3Sprite; public Sprite frame4Sprite; public Sprite frame5Sprite; public Sprite frameTitleSprite; [Header("Слои отображения изображений")] public CanvasGroup cutsceneRootGroup; public Image imageLayerA; public Image imageLayerB; public CanvasGroup layerAGroup; public CanvasGroup layerBGroup; [Header("Интерфейс субтитров")] public CanvasGroup subtitleGroup; public Text speakerLabelText; public Text subtitleBodyText; [Header("Подсказка пропуска")] public CanvasGroup skipPromptGroup; public Text skipPromptText; [Header("Аудио источники")] public AudioSource ambienceSource; public AudioSource heartbeatSource; public AudioSource sfxSource; [Header("Состояние")] public bool isPlaying = false; private bool isSkipping = false; private Coroutine cutsceneCoroutine; private PlayerMovement cachedPlayerMovement; public class CutsceneSceneData { public Sprite sprite; public string speaker; public string subtitle; public float duration; public float zoomStart; public float zoomEnd; public Vector2 panOffsetStart; public Vector2 panOffsetEnd; public System.Action onStartAction; } private List scenes = new List(); private void Awake() { _instance = this; } private void Start() { // По умолчанию скрываем до запуска if (cutsceneRootGroup != null) { cutsceneRootGroup.alpha = 0f; cutsceneRootGroup.blocksRaycasts = false; } // Автоматически запускаем кат-сцену при старте, если не была просмотрена // или если активен флаг принудительного воспроизведения bool shouldPlay = PlayerPrefs.GetInt("PlayIntroCutscene", 1) == 1; if (shouldPlay) { StartCoroutine(RoutineAutoPlayOnStart()); } } private IEnumerator RoutineAutoPlayOnStart() { yield return null; // ждем 1 кадр для инициализации всех систем игрока PlayCutscene(); } /// /// Запуск кат-сцены /// public void PlayCutscene() { if (isPlaying) return; isPlaying = true; isSkipping = false; // Блокируем движение игрока на время кат-сцены cachedPlayerMovement = FindAnyObjectByType(); if (cachedPlayerMovement != null) { cachedPlayerMovement.enabled = false; } var gun = FindAnyObjectByType(); if (gun != null) gun.enabled = false; var attack = FindAnyObjectByType(); if (attack != null) attack.enabled = false; LoadSpritesIfNull(); PrepareSceneData(); if (cutsceneCoroutine != null) StopCoroutine(cutsceneCoroutine); cutsceneCoroutine = StartCoroutine(RoutineCutsceneSequence()); } private void LoadSpritesIfNull() { if (frame1Sprite == null) frame1Sprite = Resources.Load("Cutscene/frame_1") ?? LoadSpriteFromAsset("Assets/Textures/Cutscene/frame_1.jpg"); if (frame2Sprite == null) frame2Sprite = Resources.Load("Cutscene/frame_2") ?? LoadSpriteFromAsset("Assets/Textures/Cutscene/frame_2.jpg"); if (frame3Sprite == null) frame3Sprite = Resources.Load("Cutscene/frame_3") ?? LoadSpriteFromAsset("Assets/Textures/Cutscene/frame_3.jpg"); if (frame4Sprite == null) frame4Sprite = Resources.Load("Cutscene/frame_4") ?? LoadSpriteFromAsset("Assets/Textures/Cutscene/frame_4.jpg"); if (frame5Sprite == null) frame5Sprite = Resources.Load("Cutscene/frame_5") ?? LoadSpriteFromAsset("Assets/Textures/Cutscene/frame_5.jpg"); if (frameTitleSprite == null) frameTitleSprite = Resources.Load("Cutscene/frame_title") ?? LoadSpriteFromAsset("Assets/Textures/Cutscene/frame_title.jpg"); } private Sprite LoadSpriteFromAsset(string path) { #if UNITY_EDITOR return UnityEditor.AssetDatabase.LoadAssetAtPath(path); #else return null; #endif } private void PrepareSceneData() { scenes.Clear(); // КАДР 1: ОПЕРАЦИОННАЯ scenes.Add(new CutsceneSceneData { sprite = frame1Sprite, speaker = "ГОЛОС ГГ", subtitle = "Проект «Синтезия». Они выключили свет и забрали наши глаза, надеясь, что звук станет нашим новым зрением.", duration = 7.5f, zoomStart = 1.0f, zoomEnd = 1.10f, panOffsetStart = new Vector2(0f, -10f), panOffsetEnd = new Vector2(0f, 15f), onStartAction = () => { // Запускаем фоновый гул и пиканье монитора StartAmbienceAndHeartbeat(); StartCoroutine(RoutineHeartMonitorBeeps(7.5f)); } }); // КАДР 2: БЕЗУМИЕ ВО ТЬМЕ scenes.Add(new CutsceneSceneData { sprite = frame2Sprite, speaker = "ГОЛОС ГГ", subtitle = "Но чуда не вышло. Темнота свела их с ума, оставив наедине с обостренным, невыносимым слухом.", duration = 7.0f, zoomStart = 1.05f, zoomEnd = 1.08f, panOffsetStart = new Vector2(-15f, 0f), panOffsetEnd = new Vector2(10f, 0f), onStartAction = () => { // Скрежет ногтей по стене и стон if (sfxSource != null) { sfxSource.PlayOneShot(IntroCutsceneAudio.CreateScratchAndGroanClip(), 0.9f); } } }); // КАДР 3: ПРОБУЖДЕНИЕ ГГ scenes.Add(new CutsceneSceneData { sprite = frame3Sprite, speaker = "ГОЛОС ГГ", subtitle = "Я не знаю, почему выжил только мой разум. Но теперь каждый шорох для меня — это вспышка света.", duration = 7.5f, zoomStart = 1.0f, zoomEnd = 1.11f, panOffsetStart = new Vector2(0f, -20f), panOffsetEnd = new Vector2(0f, 10f), onStartAction = () => { // Скрип сжатого металла гаечного ключа if (sfxSource != null) { sfxSource.PlayOneShot(IntroCutsceneAudio.CreateMetalGripClip(), 0.85f); } } }); // КАДР 4: УГРОЗА scenes.Add(new CutsceneSceneData { sprite = frame4Sprite, speaker = "ГОЛОС ГГ", subtitle = "Чтобы видеть путь — мне нужно шуметь.", duration = 6.2f, zoomStart = 1.12f, zoomEnd = 1.0f, panOffsetStart = new Vector2(0f, 20f), panOffsetEnd = new Vector2(0f, 0f), onStartAction = () => { // Гортанный рык мутанта вдалеке if (sfxSource != null) { sfxSource.PlayOneShot(IntroCutsceneAudio.CreateDistantGrowlClip(), 0.95f); } } }); // КАДР 5: ДЕЙСТВИЕ И ЛОГОТИП scenes.Add(new CutsceneSceneData { sprite = frame5Sprite, speaker = "ГОЛОС ГГ", subtitle = "Но каждый звук привлекает тех, кто остался во тьме навсегда.", duration = 6.5f, zoomStart = 1.02f, zoomEnd = 1.12f, panOffsetStart = new Vector2(-25f, -10f), panOffsetEnd = new Vector2(25f, 10f), onStartAction = () => { // ГРОМКИЙ УДАР МЕТАЛЛА И ВИЗГ МУТАНТА if (sfxSource != null) { sfxSource.PlayOneShot(IntroCutsceneAudio.CreateMetalClangClip(), 1.0f); sfxSource.PlayOneShot(IntroCutsceneAudio.CreateFuriousScreechClip(), 0.85f); } } }); // ФИНАЛ: ЛОГОТИП SYNTHESIA scenes.Add(new CutsceneSceneData { sprite = frameTitleSprite, speaker = "", subtitle = "", duration = 5.0f, zoomStart = 1.0f, zoomEnd = 1.05f, panOffsetStart = Vector2.zero, panOffsetEnd = Vector2.zero, onStartAction = () => { // Низкий басовый кинематографичный удар (Cinematic Boom) if (sfxSource != null) { sfxSource.PlayOneShot(IntroCutsceneAudio.CreateCinematicBoomClip(), 1.0f); } } }); } private void StartAmbienceAndHeartbeat() { if (ambienceSource != null) { ambienceSource.clip = IntroCutsceneAudio.CreateBunkerHumClip(6f); ambienceSource.loop = true; ambienceSource.volume = 0.55f; ambienceSource.Play(); } if (heartbeatSource != null) { heartbeatSource.clip = IntroCutsceneAudio.CreateHeartbeatClip(62f); heartbeatSource.loop = true; heartbeatSource.volume = 0.65f; heartbeatSource.Play(); } } private IEnumerator RoutineHeartMonitorBeeps(float duration) { float timer = 0f; AudioClip beep = IntroCutsceneAudio.CreateHeartMonitorBeepClip(); while (timer < duration && !isSkipping) { if (sfxSource != null) { sfxSource.PlayOneShot(beep, 0.4f); } yield return new WaitForSeconds(1.15f); timer += 1.15f; } } private IEnumerator RoutineCutsceneSequence() { // 1. Показываем корневой холст кат-сцены cutsceneRootGroup.alpha = 1f; cutsceneRootGroup.blocksRaycasts = true; if (skipPromptGroup != null) { skipPromptGroup.alpha = 0.75f; } bool useLayerA = true; for (int i = 0; i < scenes.Count; i++) { if (isSkipping) break; var scene = scenes[i]; // Назначаем спрайт на активный слой Image activeImg = useLayerA ? imageLayerA : imageLayerB; Image inactiveImg = useLayerA ? imageLayerB : imageLayerA; CanvasGroup activeGroup = useLayerA ? layerAGroup : layerBGroup; CanvasGroup inactiveGroup = useLayerA ? layerBGroup : layerAGroup; activeImg.sprite = scene.sprite; activeImg.rectTransform.localScale = Vector3.one * scene.zoomStart; activeImg.rectTransform.anchoredPosition = scene.panOffsetStart; // Вызываем звуковой триггер кадра scene.onStartAction?.Invoke(); // Обновляем субтитры UpdateSubtitles(scene.speaker, scene.subtitle); // Плавное перетекание (Cross-fade) float crossFadeDuration = (i == 0) ? 1.5f : 1.1f; float elapsed = 0f; while (elapsed < crossFadeDuration) { if (isSkipping) yield break; elapsed += Time.deltaTime; float t = Mathf.Clamp01(elapsed / crossFadeDuration); activeGroup.alpha = t; inactiveGroup.alpha = 1f - t; yield return null; } activeGroup.alpha = 1f; inactiveGroup.alpha = 0f; // Анимация кадра Ken Burns (панорама и наезд камеры) float sceneElapsed = 0f; float totalDuration = scene.duration; while (sceneElapsed < totalDuration) { if (isSkipping) yield break; sceneElapsed += Time.deltaTime; float progress = Mathf.Clamp01(sceneElapsed / totalDuration); // Плавная интерполяция масштаба и смещения float currentZoom = Mathf.Lerp(scene.zoomStart, scene.zoomEnd, progress); Vector2 currentPan = Vector2.Lerp(scene.panOffsetStart, scene.panOffsetEnd, progress); activeImg.rectTransform.localScale = Vector3.one * currentZoom; activeImg.rectTransform.anchoredPosition = currentPan; yield return null; } // Переключаем слой для следующего кадра useLayerA = !useLayerA; } // Финальный переход в игру yield return StartCoroutine(RoutineEndCutscene()); } private void UpdateSubtitles(string speaker, string text) { if (subtitleGroup == null) return; if (string.IsNullOrEmpty(text)) { subtitleGroup.alpha = 0f; return; } subtitleGroup.alpha = 1f; if (speakerLabelText != null) { speakerLabelText.text = speaker; } if (subtitleBodyText != null) { subtitleBodyText.text = text; } } private void Update() { if (!isPlaying || isSkipping) return; // Пропуск по клавишам ПРОБЕЛ, ESC или ENTER var keyboard = Keyboard.current; if (keyboard != null) { if (keyboard.spaceKey.wasPressedThisFrame || keyboard.escapeKey.wasPressedThisFrame || keyboard.enterKey.wasPressedThisFrame) { SkipCutscene(); } } } public void SkipCutscene() { if (isSkipping || !isPlaying) return; isSkipping = true; if (cutsceneCoroutine != null) StopCoroutine(cutsceneCoroutine); StartCoroutine(RoutineEndCutscene()); } private IEnumerator RoutineEndCutscene() { // Плавное затухание в темноту float fadeOut = 1.0f; float elapsed = 0f; float startAlpha = cutsceneRootGroup != null ? cutsceneRootGroup.alpha : 1f; // Плавное затухание эмбиента float startAmbienceVol = ambienceSource != null ? ambienceSource.volume : 0f; float startHeartVol = heartbeatSource != null ? heartbeatSource.volume : 0f; while (elapsed < fadeOut) { elapsed += Time.deltaTime; float t = Mathf.Clamp01(elapsed / fadeOut); if (cutsceneRootGroup != null) cutsceneRootGroup.alpha = Mathf.Lerp(startAlpha, 0f, t); if (ambienceSource != null) ambienceSource.volume = Mathf.Lerp(startAmbienceVol, 0f, t); if (heartbeatSource != null) heartbeatSource.volume = Mathf.Lerp(startHeartVol, 0f, t); yield return null; } if (ambienceSource != null) ambienceSource.Stop(); if (heartbeatSource != null) heartbeatSource.Stop(); if (cutsceneRootGroup != null) { cutsceneRootGroup.alpha = 0f; cutsceneRootGroup.blocksRaycasts = false; } isPlaying = false; isSkipping = false; // Помечаем, что кат-сцена была просмотрена PlayerPrefs.SetInt("PlayIntroCutscene", 0); PlayerPrefs.Save(); // Возвращаем управление игроку if (cachedPlayerMovement != null) cachedPlayerMovement.enabled = true; var gun = FindAnyObjectByType(); if (gun != null) gun.enabled = true; var attack = FindAnyObjectByType(); if (attack != null) attack.enabled = true; // Финальная вспышка эхолокации в месте старта игрока (мир оживает желтым звуковым пульсом) var player = GameObject.FindWithTag("Player") ?? GameObject.Find("Player"); if (player != null && EchoTrigger.Instance != null) { EchoTrigger.Instance.TriggerExternalWave(player.transform.position, 25f, 3.2f); } Debug.Log("[IntroCutscene] Кат-сцена завершена. Управление передано игроку!"); } /// /// Фабричный метод создания всего UI и компонентов кат-сцены /// public static IntroCutsceneController Create(Transform parentCanvas) { var old = parentCanvas.Find("IntroCutsceneRoot"); if (old != null) DestroyImmediate(old.gameObject); // 1. Корневой контейнер GameObject root = new GameObject("IntroCutsceneRoot", typeof(RectTransform), typeof(CanvasGroup)); root.transform.SetParent(parentCanvas, false); RectTransform rootRt = root.GetComponent(); rootRt.anchorMin = Vector2.zero; rootRt.anchorMax = Vector2.one; rootRt.sizeDelta = Vector2.zero; // Черный глухой фон Image bgImg = root.AddComponent(); bgImg.color = Color.black; // 2. Слой A (для перетекания) GameObject layerA = new GameObject("ImageLayerA", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image), typeof(CanvasGroup)); layerA.transform.SetParent(root.transform, false); RectTransform layerARt = layerA.GetComponent(); layerARt.anchorMin = Vector2.zero; layerARt.anchorMax = Vector2.one; layerARt.sizeDelta = Vector2.zero; Image imgA = layerA.GetComponent(); imgA.preserveAspect = true; CanvasGroup groupA = layerA.GetComponent(); groupA.alpha = 0f; // 3. Слой B (для перетекания) GameObject layerB = new GameObject("ImageLayerB", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image), typeof(CanvasGroup)); layerB.transform.SetParent(root.transform, false); RectTransform layerBRt = layerB.GetComponent(); layerBRt.anchorMin = Vector2.zero; layerBRt.anchorMax = Vector2.one; layerBRt.sizeDelta = Vector2.zero; Image imgB = layerB.GetComponent(); imgB.preserveAspect = true; CanvasGroup groupB = layerB.GetComponent(); groupB.alpha = 0f; // 4. Панель субтитров Font defaultFont = Resources.GetBuiltinResource("LegacyRuntime.ttf"); GameObject subPanel = new GameObject("SubtitlePanel", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image), typeof(CanvasGroup)); subPanel.transform.SetParent(root.transform, false); RectTransform subRt = subPanel.GetComponent(); subRt.anchorMin = new Vector2(0.08f, 0.04f); subRt.anchorMax = new Vector2(0.92f, 0.22f); subRt.sizeDelta = Vector2.zero; Image subBg = subPanel.GetComponent(); subBg.color = new Color(0.03f, 0.03f, 0.04f, 0.78f); Outline subOutline = subPanel.AddComponent(); subOutline.effectColor = new Color(0.85f, 0.75f, 0.25f, 0.25f); subOutline.effectDistance = new Vector2(1f, -1f); // Имя говорящего GameObject speakerObj = new GameObject("SpeakerLabel", typeof(RectTransform), typeof(CanvasRenderer), typeof(Text)); speakerObj.transform.SetParent(subPanel.transform, false); RectTransform spkRt = speakerObj.GetComponent(); spkRt.anchorMin = new Vector2(0f, 0.65f); spkRt.anchorMax = new Vector2(1f, 1.0f); spkRt.offsetMin = new Vector2(20f, 0f); spkRt.offsetMax = new Vector2(-20f, -8f); Text spkText = speakerObj.GetComponent(); spkText.font = defaultFont; spkText.fontSize = 15; spkText.fontStyle = FontStyle.Bold; spkText.alignment = TextAnchor.MiddleCenter; spkText.color = new Color(0.95f, 0.8f, 0.25f, 1.0f); spkText.text = "ГОЛОС ГГ"; // Текст субтитров GameObject subTextObj = new GameObject("SubtitleText", typeof(RectTransform), typeof(CanvasRenderer), typeof(Text)); subTextObj.transform.SetParent(subPanel.transform, false); RectTransform stRt = subTextObj.GetComponent(); stRt.anchorMin = new Vector2(0f, 0f); stRt.anchorMax = new Vector2(1f, 0.65f); stRt.offsetMin = new Vector2(30f, 8f); stRt.offsetMax = new Vector2(-30f, 0f); Text subBody = subTextObj.GetComponent(); subBody.font = defaultFont; subBody.fontSize = 18; subBody.lineSpacing = 1.25f; subBody.alignment = TextAnchor.MiddleCenter; subBody.color = new Color(0.95f, 0.95f, 0.95f, 0.95f); subBody.text = "..."; // 5. Подсказка пропуска GameObject skipObj = new GameObject("SkipPrompt", typeof(RectTransform), typeof(CanvasRenderer), typeof(Text), typeof(CanvasGroup)); skipObj.transform.SetParent(root.transform, false); RectTransform skipRt = skipObj.GetComponent(); skipRt.anchorMin = new Vector2(0.68f, 0.015f); skipRt.anchorMax = new Vector2(0.98f, 0.05f); skipRt.sizeDelta = Vector2.zero; Text skipText = skipObj.GetComponent(); skipText.font = defaultFont; skipText.fontSize = 13; skipText.alignment = TextAnchor.MiddleRight; skipText.color = new Color(0.7f, 0.7f, 0.7f, 0.75f); skipText.text = "[ ПРОБЕЛ / ESC : ПРОПУСТИТЬ ]"; // 6. Аудио источники GameObject audioHolder = new GameObject("CutsceneAudioSources"); audioHolder.transform.SetParent(root.transform, false); AudioSource ambSrc = audioHolder.AddComponent(); ambSrc.playOnAwake = false; AudioSource heartSrc = audioHolder.AddComponent(); heartSrc.playOnAwake = false; AudioSource sfxSrc = audioHolder.AddComponent(); sfxSrc.playOnAwake = false; // 7. Сборка компонента IntroCutsceneController controller = root.AddComponent(); controller.cutsceneRootGroup = root.GetComponent(); controller.cutsceneRootGroup.alpha = 0f; controller.cutsceneRootGroup.blocksRaycasts = false; controller.imageLayerA = imgA; controller.imageLayerB = imgB; controller.layerAGroup = groupA; controller.layerBGroup = groupB; controller.subtitleGroup = subPanel.GetComponent(); controller.speakerLabelText = spkText; controller.subtitleBodyText = subBody; controller.skipPromptGroup = skipObj.GetComponent(); controller.skipPromptText = skipText; controller.ambienceSource = ambSrc; controller.heartbeatSource = heartSrc; controller.sfxSource = sfxSrc; return controller; } }