시간

Gameplay·UI·Background 세 개의 독립 클럭 위에서 흐르는 일시정지 가능한 게임 시간 — 클럭마다 자체 속도 제어 제공.

세 개의 독립 클럭 — Gameplay, UI, Background · 클럭별 타임 스케일과 중첩 안전 일시정지 · No-op 대체 구현: NullTimeService

CGS는 고전적인 일시정지 메뉴 버그 — 음악이 끊기고, UI가 얼어붙고, 메뉴가 열려 있는데 게임플레이가 뒤에서 계속 돌아가는 문제 — 를 **ITimeService**로 방지합니다. Unity의 전역 Time.timeScale모든 것을 한꺼번에 멈춥니다. Time 서비스는 대신 세 개의 독립 클럭을 돌립니다:

클럭대표 용도일시정지 시
Clock.Gameplay이동, AI, 전투, 물리 인접 로직메뉴가 열리면 이 클럭을 일시정지
Clock.UI메뉴·HUD 애니메이션계속 흐르므로 메뉴 애니메이션 유지
Clock.Background음악, 앰비언트 오디오계속 흐르므로 음악이 끊기지 않음

Time.deltaTime 대신 _time.DeltaTime(Clock.Gameplay)으로 시간을 읽으세요. _time.SetTimeScale(Clock.Gameplay, 0.3f)로 클럭 하나만 늦추면 시네마틱 슬로 모션이 됩니다 — UI와 음악은 원래 속도를 유지합니다. 일시정지는 안전하게 중첩됩니다: 세 시스템이 한 클럭을 일시정지하면, 셋 모두 재개할 때까지 클럭은 멈춰 있습니다.

서비스 가져오기

Awake()Start()에서 리졸브하고 참조를 캐시하세요. Update() 안에서 리졸브하지 마세요 — 매 프레임 리졸브는 낭비입니다.

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)]
public class MyGameplay : MonoBehaviour
{
    private ITimeService _time;

    private void Awake()
    {
        _time = ServiceLocator.Resolve<ITimeService>();
    }

    private void Update()
    {
        float dt = _time.DeltaTime(Clock.Gameplay);
        // Use dt for movement, AI, and other game logic.
    }
}

API 레퍼런스

시간 읽기 (모두 O(1), 메인 스레드 전용)

float DeltaTime(Clock clock)         // Scaled, pause-aware frame delta; 0 while paused
float UnscaledDeltaTime(Clock clock) // Raw frame delta; ignores pause and time scale
float FixedDeltaTime(Clock clock)    // Fixed-step delta, scaled and pause-aware
double Time(Clock clock)             // Accumulated game time (double precision;
                                     // affected by pause and time scale)
double UnscaledTime(Clock clock)     // Accumulated real time; always increases.
                                     // Use for elapsed-time measurement and timeouts.

타임 스케일 제어

타임 스케일은 속도 배율입니다: 1.0은 정상 속도, 0.5는 절반 속도, 0은 일시정지처럼 동작합니다.

float GetTimeScale(Clock clock)             // The clock's current scale
void SetTimeScale(Clock clock, float scale) // Set one clock's scale (0 allowed)
void SetGlobalTimeScale(float scale)        // Set all three clocks at once

일시정지 제어 (카운터 기반, 중첩 안전)

void Pause(Clock clock)        // Increment the clock's pause counter
void Resume(Clock clock)       // Decrement it; going below zero throws
bool IsPaused(Clock clock)     // true while the counter is above 0
int GetPauseCount(Clock clock) // The current counter value (diagnostic)
void PauseAll()                // Pause all three clocks
void ResumeAll()               // Resume all three; clocks already at zero are
                               // skipped, so this never throws

겹치는 일시정지 소스를 안전하게 만드는 것이 바로 이 카운터입니다: 일시정지 메뉴, 컷씬, 포커스 상실 시 일시정지 핸들러가 서로의 존재를 몰라도 각자 Pause(Clock.Gameplay)를 호출할 수 있고, 마지막 하나가 Resume을 호출할 때에만 게임플레이가 재개됩니다.

전체 예제

게임플레이는 멈추지만 UI는 계속 애니메이션되는 일시정지 메뉴:

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)]
public class PauseMenu : MonoBehaviour
{
    private ITimeService _time;

    private void Awake() => _time = ServiceLocator.Resolve<ITimeService>();

    public void Open()
    {
        _time.Pause(Clock.Gameplay);
        gameObject.SetActive(true);
    }

    public void Close()
    {
        _time.Resume(Clock.Gameplay);
        gameObject.SetActive(false);
    }
}

게임플레이에만 적용되는 시네마틱 슬로 모션:

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)]
public class BossIntro : MonoBehaviour
{
    private ITimeService _time;

    private void Awake() => _time = ServiceLocator.Resolve<ITimeService>();

    public void BeginSlowMotion() => _time.SetTimeScale(Clock.Gameplay, 0.3f);

    public void EndSlowMotion() => _time.SetTimeScale(Clock.Gameplay, 1.0f);
}

클럭 분리는 데모 씬 Demo/MotionLab.unity에서 실제로 확인할 수 있습니다: Gameplay 타임 스케일 슬라이더가 SetTimeScale(Clock.Gameplay, value)로 애니메이션 갤러리를 늦추고 멈추는 동안, Clock.UI 위의 스피너는 아무 영향 없이 계속 돕니다.

끄기

ServiceLocator.Replace<ITimeService>(new NullTimeService());

NullTimeService는 Unity 자체의 Time 값을 그대로 통과시킵니다. 모든 델타·시간 조회가 UnityEngine.Time 값을 반환하고, 일시정지와 타임 스케일 호출은 아무것도 하지 않습니다. 별도의 클럭이 필요 없는 게임 — 프로토타입, 또는 Time.timeScale을 직접 관리하는 프로젝트 — 에서 사용하세요.

교체한 인스턴스는 Dispose하세요. ServiceLocator.Replace<T>는 레지스트리 항목만 바꿀 뿐입니다 — 교체되는 인스턴스를 dispose하지 않습니다. 여러분이 아직 그 참조를 들고 있는지 알 수 없기 때문입니다. TimeService[CGS] TimeService Ticker GameObject(Play 모드에서 DontDestroyOnLoad로 표시됨)를 소유하므로, 교체됐지만 dispose되지 않은 인스턴스는 그 GameObject를 살려 둔 채 계속 틱합니다: 세션이 끝날 때까지 사라지지 않는 두 번째 티커가 Hierarchy에 남습니다. 플레이어 빌드에는 이를 정리해 줄 teardown 단계도 없습니다.

var previous = ServiceLocator.Resolve<ITimeService>() as System.IDisposable;
ServiceLocator.Replace<ITimeService>(new NullTimeService());
previous?.Dispose();   // destroys the old [CGS] TimeService Ticker

스왑 후에 dispose하세요. 그래야 그 사이에 이미 dispose된 인스턴스가 리졸브되는 일이 없습니다. 내부에 [CGS] … GameObject를 소유한 모든 서비스 — Object Pool, Audio, UI Framework, Scheduler, Tween, Tween Sequencing — 에 같은 규칙이 적용됩니다.

흔한 함정

  • 메인 스레드 전용. Debug 빌드에서는 워커 스레드(Task.Run, 스레드 풀 등)에서 Time 서비스를 호출하면 InvalidOperationException을 던집니다. 메인 스레드에서 리졸브하고 인스턴스를 캐시하세요.

  • Update()에서 쓰는 곳은 인스턴스를 캐시하세요. ServiceLocator.Resolve<T>()는 빠른 딕셔너리 조회지만 공짜는 아닙니다. 매 프레임 시간을 읽는다면 Awake()에서 _time을 캐시하세요.

  • 중첩 일시정지는 카운터 기반입니다. Pause(Clock.Gameplay)를 두 번 호출했다면 Resume()도 두 번 호출해야 합니다. 이것이 겹치는 일시정지 소스들이 서로를 밟지 않게 하는 장치입니다 — 다만 짝이 맞지 않는 Resume()은 Debug 빌드에서 예외를 던지므로, 짝을 맞추세요.

  • UnityEngine.Time.timeScale을 건드리지 마세요. Time 서비스는 Unity의 전역 타임 스케일이 1.0으로 유지된다고 가정하고 Time.unscaledDeltaTime을 기준선으로 사용합니다. Time.timeScale을 직접 설정하면 클럭별 분리가 깨집니다. 전역 슬로 모션에는 SetGlobalTimeScale()을 사용하세요.

  • UnscaledTime은 항상 앞으로만 갑니다. 일시정지와 타임 스케일을 완전히 무시하며, 도메인 리로드 시에만 리셋됩니다. 경과 시간, 로딩 화면 타임아웃, 속도 제한 등 게임 시간이 측정에 영향을 주면 안 되는 곳에 사용하세요. 값은 모든 클럭에서 동일하며, 파라미터는 가독성을 위해 존재합니다.

관련 페이지