Kursach_StealthAction/Assets/Scripts/IntroCutsceneController.cs

643 lines
24 KiB
C#
Raw Permalink 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.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.InputSystem;
/// <summary>
/// Контроллер вступительной кат-сцены «Синтезия» (2D-арты / слайд-шоу).
/// Воспроизводит 5 атмосферных иллюстрированных кадров с эффектом Ken Burns (панорамирование/зум),
/// плавными растворениями (cross-fade), процедурным звуковым сопровождением и финальным логотипом.
/// </summary>
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<CutsceneSceneData> scenes = new List<CutsceneSceneData>();
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();
}
/// <summary>
/// Запуск кат-сцены
/// </summary>
public void PlayCutscene()
{
if (isPlaying) return;
isPlaying = true;
isSkipping = false;
// Блокируем движение игрока на время кат-сцены
cachedPlayerMovement = FindAnyObjectByType<PlayerMovement>();
if (cachedPlayerMovement != null)
{
cachedPlayerMovement.enabled = false;
}
var gun = FindAnyObjectByType<PlayerGun>();
if (gun != null) gun.enabled = false;
var attack = FindAnyObjectByType<PlayerAttack>();
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<Sprite>("Cutscene/frame_1") ?? LoadSpriteFromAsset("Assets/Textures/Cutscene/frame_1.jpg");
if (frame2Sprite == null) frame2Sprite = Resources.Load<Sprite>("Cutscene/frame_2") ?? LoadSpriteFromAsset("Assets/Textures/Cutscene/frame_2.jpg");
if (frame3Sprite == null) frame3Sprite = Resources.Load<Sprite>("Cutscene/frame_3") ?? LoadSpriteFromAsset("Assets/Textures/Cutscene/frame_3.jpg");
if (frame4Sprite == null) frame4Sprite = Resources.Load<Sprite>("Cutscene/frame_4") ?? LoadSpriteFromAsset("Assets/Textures/Cutscene/frame_4.jpg");
if (frame5Sprite == null) frame5Sprite = Resources.Load<Sprite>("Cutscene/frame_5") ?? LoadSpriteFromAsset("Assets/Textures/Cutscene/frame_5.jpg");
if (frameTitleSprite == null) frameTitleSprite = Resources.Load<Sprite>("Cutscene/frame_title") ?? LoadSpriteFromAsset("Assets/Textures/Cutscene/frame_title.jpg");
}
private Sprite LoadSpriteFromAsset(string path)
{
#if UNITY_EDITOR
return UnityEditor.AssetDatabase.LoadAssetAtPath<Sprite>(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<PlayerGun>();
if (gun != null) gun.enabled = true;
var attack = FindAnyObjectByType<PlayerAttack>();
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("<color=green>[IntroCutscene] Кат-сцена завершена. Управление передано игроку!</color>");
}
/// <summary>
/// Фабричный метод создания всего UI и компонентов кат-сцены
/// </summary>
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<RectTransform>();
rootRt.anchorMin = Vector2.zero;
rootRt.anchorMax = Vector2.one;
rootRt.sizeDelta = Vector2.zero;
// Черный глухой фон
Image bgImg = root.AddComponent<Image>();
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<RectTransform>();
layerARt.anchorMin = Vector2.zero;
layerARt.anchorMax = Vector2.one;
layerARt.sizeDelta = Vector2.zero;
Image imgA = layerA.GetComponent<Image>();
imgA.preserveAspect = true;
CanvasGroup groupA = layerA.GetComponent<CanvasGroup>();
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<RectTransform>();
layerBRt.anchorMin = Vector2.zero;
layerBRt.anchorMax = Vector2.one;
layerBRt.sizeDelta = Vector2.zero;
Image imgB = layerB.GetComponent<Image>();
imgB.preserveAspect = true;
CanvasGroup groupB = layerB.GetComponent<CanvasGroup>();
groupB.alpha = 0f;
// 4. Панель субтитров
Font defaultFont = Resources.GetBuiltinResource<Font>("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<RectTransform>();
subRt.anchorMin = new Vector2(0.08f, 0.04f);
subRt.anchorMax = new Vector2(0.92f, 0.22f);
subRt.sizeDelta = Vector2.zero;
Image subBg = subPanel.GetComponent<Image>();
subBg.color = new Color(0.03f, 0.03f, 0.04f, 0.78f);
Outline subOutline = subPanel.AddComponent<Outline>();
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<RectTransform>();
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<Text>();
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<RectTransform>();
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<Text>();
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<RectTransform>();
skipRt.anchorMin = new Vector2(0.68f, 0.015f);
skipRt.anchorMax = new Vector2(0.98f, 0.05f);
skipRt.sizeDelta = Vector2.zero;
Text skipText = skipObj.GetComponent<Text>();
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<AudioSource>();
ambSrc.playOnAwake = false;
AudioSource heartSrc = audioHolder.AddComponent<AudioSource>();
heartSrc.playOnAwake = false;
AudioSource sfxSrc = audioHolder.AddComponent<AudioSource>();
sfxSrc.playOnAwake = false;
// 7. Сборка компонента
IntroCutsceneController controller = root.AddComponent<IntroCutsceneController>();
controller.cutsceneRootGroup = root.GetComponent<CanvasGroup>();
controller.cutsceneRootGroup.alpha = 0f;
controller.cutsceneRootGroup.blocksRaycasts = false;
controller.imageLayerA = imgA;
controller.imageLayerB = imgB;
controller.layerAGroup = groupA;
controller.layerBGroup = groupB;
controller.subtitleGroup = subPanel.GetComponent<CanvasGroup>();
controller.speakerLabelText = spkText;
controller.subtitleBodyText = subBody;
controller.skipPromptGroup = skipObj.GetComponent<CanvasGroup>();
controller.skipPromptText = skipText;
controller.ambienceSource = ambSrc;
controller.heartbeatSource = heartSrc;
controller.sfxSource = sfxSrc;
return controller;
}
}