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;
}
}