diff --git a/Component/EscapeHandlerUI.cs b/Component/EscapeHandlerUI.cs new file mode 100644 index 0000000..63187f3 --- /dev/null +++ b/Component/EscapeHandlerUI.cs @@ -0,0 +1,130 @@ +using System.Diagnostics; +using TMPro; +using UnityEngine; +using UnityEngine.UI; + +namespace ADOFAIRenderer.Component +{ + public class EscapeHandlerUI : MonoBehaviour + { + private GameObject _containerObj = null!; + private TextMeshProUGUI _textComponent = null!; + private Stopwatch? _escapeStopwatch; + + private void Start() + { + // UI 레이어(5) 설정 + gameObject.layer = 5; + + // Canvas 설정 + var canvas = gameObject.AddComponent(); + canvas.renderMode = RenderMode.ScreenSpaceOverlay; + canvas.sortingOrder = 10000; // 디버그 모니터보다 위에 오도록 설정 + + // 해상도 대응 캔버스 스케일러 설정 + var scaler = gameObject.AddComponent(); + scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; + scaler.referenceResolution = new Vector2(1920, 1080); + scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight; + scaler.matchWidthOrHeight = 0.5f; + + gameObject.AddComponent(); + + // 1. 컨테이너 및 배경 패널 오브젝트 생성 + _containerObj = new GameObject("EscapeContainer"); + _containerObj.transform.SetParent(transform, false); + _containerObj.layer = 5; + + var bgImage = _containerObj.AddComponent(); + // 고급스러운 다크톤 아크릴 감성 + bgImage.color = new Color(0.05f, 0.05f, 0.07f, 0.85f); + + var containerRect = _containerObj.GetComponent(); + containerRect.anchorMin = new Vector2(0.5f, 0.5f); + containerRect.anchorMax = new Vector2(0.5f, 0.5f); + containerRect.pivot = new Vector2(0.5f, 0.5f); + containerRect.anchoredPosition = Vector2.zero; // 화면 중앙 + containerRect.sizeDelta = new Vector2(460, 160); + + // 2. 텍스트 오브젝트 생성 + var textObj = new GameObject("EscapeText"); + textObj.transform.SetParent(_containerObj.transform, false); + textObj.layer = 5; + + _textComponent = textObj.AddComponent(); + _textComponent.alignment = TextAlignmentOptions.Center; + _textComponent.fontSize = 60; + _textComponent.textWrappingMode = TextWrappingModes.NoWrap; + _textComponent.richText = true; + + // 게임의 기본 폰트 적용 (RDString.fontData.fontTMP) + if (RDString.fontData.fontTMP != null) + _textComponent.font = RDString.fontData.fontTMP; + + var textRect = textObj.GetComponent(); + textRect.anchorMin = Vector2.zero; + textRect.anchorMax = Vector2.one; + textRect.offsetMin = new Vector2(10, 10); + textRect.offsetMax = new Vector2(-10, -10); + + // 초기 상태는 비활성화 + _containerObj.SetActive(false); + } + + private void Update() + { + // 렌더링이 더 이상 활성화되어 있지 않으면 자가 파괴 + if (!RenderMod.IsRendering) + { + Destroy(gameObject); + return; + } + + if (Input.GetKey(KeyCode.Escape)) + { + if (_escapeStopwatch == null) + { + _escapeStopwatch = new Stopwatch(); + _escapeStopwatch.Start(); + _containerObj.SetActive(true); + } + + var elapsed = (float)_escapeStopwatch.Elapsed.TotalSeconds; + var remaining = Mathf.Max(0f, 3f - elapsed); + + // 남은 시간 텍스트 업데이트 + _textComponent.text = $"탈출까지 {remaining:F1}초...<\nEsc 키를 계속 누르고 계세요"; + + // 마이크로 펄스 애니메이션 적용 (누르는 동안 컨테이너 전체 크기가 부드럽게 커짐) + var scale = 1.0f + (elapsed / 3.0f) * 0.15f; + _containerObj.transform.localScale = new Vector3(scale, scale, 1f); + + if (elapsed < 3f) return; + + _escapeStopwatch.Stop(); + _escapeStopwatch = null; + _containerObj.transform.localScale = Vector3.one; // 스케일 초기화 + _containerObj.SetActive(false); + + // 렌더링 취소 및 씬 이탈 + if (RenderManager.Instance) + RenderManager.Instance.CancelRender(); + } + else + { + if (_escapeStopwatch == null) return; + _escapeStopwatch.Stop(); + _escapeStopwatch = null; + _containerObj.transform.localScale = Vector3.one; // 스케일 초기화 + _containerObj.SetActive(false); + } + } + + private void OnDestroy() + { + if (_escapeStopwatch == null) return; + _escapeStopwatch.Stop(); + _escapeStopwatch = null; + } + } +} diff --git a/Component/RenderManager.cs b/Component/RenderManager.cs index c1a341f..7d596e3 100644 --- a/Component/RenderManager.cs +++ b/Component/RenderManager.cs @@ -19,6 +19,8 @@ namespace ADOFAIRenderer.Component public static RenderManager Instance = null!; private static int _originalInputOffset; private static GameObject? _debugMonitorObj; + private static GameObject? _escapeHandlerObj; + private static string? _currentOutPath; public static RenderSettings Configuration; @@ -96,6 +98,13 @@ namespace ADOFAIRenderer.Component RenderMod.IsRendering = true; Countdown = 3; + if (!_escapeHandlerObj) + { + _escapeHandlerObj = new GameObject("EscapeHandlerCanvas"); + _escapeHandlerObj.AddComponent(); + DontDestroyOnLoad(_escapeHandlerObj); + } + scrController.instance!.StartCoroutine(SetupPlay()); } @@ -103,10 +112,8 @@ namespace ADOFAIRenderer.Component if (!scrController.instance || scrController.instance.state == States.Fail || scrController.instance.state == States.Fail2) { RenderMod.IsRendering = RenderMod.IsCapturing = false; - - Time.timeScale = 1f; - scrConductor.currentPreset.inputOffset = _originalInputOffset; StopFFmpeg(false); + EndRender(); } else if (scrController.instance.state == States.Won) { @@ -119,8 +126,6 @@ namespace ADOFAIRenderer.Component if (FrameCount - WonFrame < extraFrames) return; RenderMod.IsRendering = RenderMod.IsCapturing = false; - Time.timeScale = 1f; - scrConductor.currentPreset.inputOffset = _originalInputOffset; StopFFmpeg(true); } } @@ -236,6 +241,7 @@ namespace ADOFAIRenderer.Component var startTime = DateTime.Now; var fileName = startTime.ToString("yyyy-MM-dd HH-mm-ss") + ext; + _currentOutPath = Path.Combine(MelonEnvironment.GameRootDirectory, "Render", fileName); RenderThread = new Thread(() => { @@ -244,10 +250,9 @@ namespace ADOFAIRenderer.Component var workingFps = (float)(targetFps % videoFps == 0.0 ? videoFps : targetFps); var videoFFmpeg = new VideoFFmpeg(Configuration.RenderWidth, Configuration.RenderHeight, (int)workingFps, (int)videoFps, selectedCodec); - var outPath = Path.Combine(MelonEnvironment.GameRootDirectory, "Render", fileName); SetupCaptureDirectory(); - videoFFmpeg.Start(outPath, bitrate); + videoFFmpeg.Start(_currentOutPath, bitrate); int num = targetFps % videoFps != 0.0 ? 1 : (int)(targetFps / videoFps); @@ -304,32 +309,100 @@ namespace ADOFAIRenderer.Component RenderThread.Start(); } + public void CancelRender() + { + RenderMod.IsRendering = RenderMod.IsCapturing = false; + StopFFmpeg(false); + EndRender(); + } + private void StopFFmpeg(bool success) { EndRequested = true; + SkipPressToStart = false; + _renderSetup = false; + if (_escapeHandlerObj) + { + Destroy(_escapeHandlerObj); + _escapeHandlerObj = null; + } if (success) { StartCoroutine(StopRenderCoroutine()); } else { - Melon.Logger.Error("렌더링이 실패했거나 취소되었습니다."); + Melon.Logger.Error("렌더링이 실패했거나 취소되었습니다. 상태 및 리소스를 안전하게 청소합니다."); + + // 1. 메모리 누수 방지: 큐에 렌트된 임시 프레임 버퍼 풀 반환 및 클리어 + lock (ImageQueue) + { + foreach (var kvp in ImageQueue) + { + if (kvp.Value != null && kvp.Value.Length > 0) + { + System.Buffers.ArrayPool.Shared.Return(kvp.Value); + } + } + ImageQueue.Clear(); + } + + // 디버그 모니터 UI 정리 if (_debugMonitorObj) { Destroy(_debugMonitorObj); _debugMonitorObj = null; } + + // 2. 렌더링 스레드가 큐 비어 있음을 감지하고 break 하도록 대기 if (RenderThread is { IsAlive: true }) { RenderThread.Join(2000); } + + // 3. 인게임 카메라 및 렌더링용 RTCam 상태 안전 원복 + if (scrCamera.instance) + { + scrCamera.instance.forceRTCam = false; + scrCamera.instance.SetupRTCam(false); + + var hook = scrCamera.instance.GetComponent(); + if (hook) Destroy(hook); + } + + // 4. 인게임 실패 방지 플래그 원상 복구 + if (scrController.instance) + { + scrController.instance.noFail = false; + scrController.instance.noFailInfiniteMargin = false; + } + + // 5. 취소된 미완성 임시 동영상 파일 즉각 삭제 + if (!string.IsNullOrEmpty(_currentOutPath) && File.Exists(_currentOutPath)) + { + try + { + File.Delete(_currentOutPath); + Melon.Logger.Msg($"[File Cleanup] 미완성 동영상 삭제 완료: {_currentOutPath}"); + } + catch (Exception ex) + { + Melon.Logger.Error($"[File Cleanup] 임시 동영상 삭제 실패: {ex.Message}"); + } + _currentOutPath = null; + } } } private static IEnumerator StopRenderCoroutine() { yield return new WaitUntil(() => EndSuccessful); + EndRender(); + } + private static void EndRender() + { + Time.timeScale = 1f; if (_debugMonitorObj) { Destroy(_debugMonitorObj); @@ -337,6 +410,10 @@ namespace ADOFAIRenderer.Component } RDC.auto = false; + scrConductor.currentPreset.inputOffset = _originalInputOffset; + GCS.customLevelPaths = null; + GCS.customLevelId = null; + ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor"); } } diff --git a/RenderMod.cs b/RenderMod.cs index 64a3fa3..4e2997a 100644 --- a/RenderMod.cs +++ b/RenderMod.cs @@ -9,7 +9,7 @@ using UnityEngine.EventSystems; using UnityEngine.Rendering; using Object = UnityEngine.Object; -[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.0.3-BETA", "ADOFAI Editor Forum")] +[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.0.4-BETA", "ADOFAI Editor Forum")] [assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")] namespace ADOFAIRenderer diff --git a/UI/RenderButton.cs b/UI/RenderButton.cs index b6418a8..b6a586e 100644 --- a/UI/RenderButton.cs +++ b/UI/RenderButton.cs @@ -36,6 +36,14 @@ namespace ADOFAIRenderer.UI codecDropdown.AddOptions(options); } + private void Update() + { + if (Input.GetKeyDown(KeyCode.Escape)) + { + ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor"); + } + } + public void OnClick() { if (IsAnyFieldEmpty()) return;