도전 과제 & 스탯

플레이어 스탯을 추적하고, 임계값에서 도전 과제를 자동 해금하고, 진행 상황을 디스크에 저장하고, 해금 이벤트를 발행합니다.

도전 과제를 데이터 테이블로 선언하면, 추적·해금·저장은 프레임워크가 맡습니다.

인터페이스IAchievementService
끄기 스위치NullAchievementService
어셈블리CommonGameSystem.Core
시작부팅 시 자동 등록 — 정의 테이블은 직접 로드합니다

주요 기능

도전 과제와 스탯은 플레이어의 진행을 추적하고 성취를 보상합니다. 둘 다 ScriptableObject 테이블로 선언합니다: 스탯은 이름 있는 카운터("enemies_defeated", "playtime_seconds")이고, 도전 과제는 id와 표시 텍스트이며, 선택적으로 목표 임계값과 함께 스탯에 바인딩됩니다. 서비스는 스탯 변경을 기록하고, 스탯이 움직이는 순간 해금 조건을 검사하고, Save/Load 서비스를 통해 모든 것을 디스크에 저장하며, 도전 과제가 해금될 때마다 Event Bus에 AchievementUnlocked 이벤트를 발행합니다.

역할 분담은 의도적입니다: 프레임워크는 추적·해금·영속화를 소유하고, 토스트나 팝업은 여러분의 UI가 소유합니다. 모듈은 완전히 헤드리스입니다 — 렌더링 코드가 전혀 없으므로 어떤 UI 접근 방식과도 함께 동작합니다.

빠른 시작

테이블을 한 번 로드한 뒤, 게임플레이 코드에서 스탯을 기록하세요:

var achievements = ServiceLocator.Resolve<IAchievementService>();
achievements.AddToStat("enemies_defeated", 1);

해금 이벤트를 구독해 알림을 표시하세요:

ServiceLocator.Resolve<IEventBus>().Subscribe<AchievementUnlocked>(evt =>
{
    Debug.Log($"Unlocked: {evt.AchievementId}");
});

API 레퍼런스

테이블 로딩

  • void LoadTables(IReadOnlyList<AchievementTableAsset> achievements, IReadOnlyList<StatTableAsset> stats) — 반드시 가장 먼저 호출해야 합니다. 정의와 조회 인덱스를 구축합니다. 다시 호출하면 모든 것을 교체하고 메모리 내 상태를 리셋합니다.

스탯 연산

  • long AddToStat(string statId, long delta) — 스탯에 더하거나 뺍니다. 오버플로에 안전하며 기본적으로 0 아래로 내려가지 않습니다. 그 스탯에 바인딩된 도전 과제를 즉시 재검사합니다. 새 값을 반환합니다.
  • long SetStat(string statId, long value) — 스탯을 절대값으로 설정합니다. 같은 하한과 임계값 검사가 적용됩니다. 저장된 값을 반환합니다.
  • long GetStat(string statId) — 현재 값을 읽습니다. 알 수 없는 id는 0L을 반환합니다. 아무것도 할당하지 않습니다.

해금

  • void Unlock(string achievementId) — 스탯에 바인딩되지 않은 도전 과제(스토리 마일스톤, 비밀 발견)를 직접 해금합니다. 두 번 호출해도 안전합니다 — 이미 해금된 도전 과제는 두 번째 이벤트를 발생시키지 않습니다.
  • bool IsUnlocked(string achievementId) — 도전 과제의 해금 여부. 알 수 없는 id는 false를 반환합니다.
  • bool TryGetProgress(string achievementId, out AchievementProgress progress) — 진행 스냅샷(현재 값, 목표, 완료 비율, 상태)을 채웁니다. 스탯 바인딩 여부와 무관하게 정의된 도전 과제라면 true를 반환하고, 알 수 없는 id일 때만 false를 반환합니다.

쿼리

  • void GetVisible(List<AchievementDefinition> results) — 보이는 도전 과제(숨김이 아니거나, 숨김이지만 이미 해금됨)로 리스트를 채웁니다. 먼저 리스트를 비웁니다. 아무것도 할당하지 않습니다 — 리스트는 여러분 소유입니다.
  • void GetAll(List<AchievementDefinition> results) — 잠긴 숨김 항목까지 포함해 모든 정의로 리스트를 채웁니다. 먼저 리스트를 비웁니다. 아무것도 할당하지 않습니다.
  • bool ContainsAchievement(string id) — 도전 과제 id가 정의되어 있는지 여부.
  • bool ContainsStat(string id) — 스탯 id가 정의되어 있는지 여부.

영속화

  • SaveResult Save() — Save/Load 서비스를 통해 메모리 내 상태를 디스크에 씁니다. 변경이 없으면 쓰기를 건너뜁니다. 저장 상태를 반환합니다.
  • void Load()"achievements"라는 이름의 저장 슬롯에서 저장된 상태를 읽습니다. 저장이 없거나 손상되었으면 조용히 빈 상태로 로드됩니다. 이미 해금된 도전 과제는 이벤트를 다시 발생시키지 않습니다.
  • void ResetAll() — 모든 스탯을 초기값으로 되돌리고 모든 도전 과제를 잠급니다 — 메모리 내에서만. 리셋을 영속화하려면 이후에 Save()를 호출하세요.

예제: 전체 셋업

using System.Collections.Generic;
using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)] // Run after the framework has started.
public class AchievementManager : MonoBehaviour
{
    [SerializeField] private AchievementTableAsset[] _achievementTables; // assign in the Inspector
    [SerializeField] private StatTableAsset[] _statTables;               // assign in the Inspector

    private IAchievementService _achievements;
    private IEventBus _bus;

    private void Start()
    {
        _achievements = ServiceLocator.Resolve<IAchievementService>();
        _bus = ServiceLocator.Resolve<IEventBus>();

        // Load the table definitions first, then the saved state from disk.
        _achievements.LoadTables(_achievementTables, _statTables);
        _achievements.Load();

        // Subscribe to unlock events.
        _bus.Subscribe<AchievementUnlocked>(OnAchievementUnlocked);
    }

    public void OnEnemyDefeated()
    {
        long newCount = _achievements.AddToStat("enemies_defeated", 1);
        Debug.Log($"Enemies defeated: {newCount}");
    }

    private void OnAchievementUnlocked(AchievementUnlocked evt)
    {
        Debug.Log($"Achievement unlocked: {evt.AchievementId}");
        // Show your toast or popup here.
    }
}

끄기

ServiceLocator.Replace<IAchievementService>(new NullAchievementService());

모듈 전체가 비활성화됩니다: 테이블 로딩은 아무것도 하지 않고, 모든 변경 연산은 no-op이며, GetStat0L을, IsUnlockedfalse를 반환하고, 디스크 쓰기도 이벤트 발행도 일어나지 않습니다. 도전 과제 없이 테스트하거나 최소 빌드를 출시할 때 사용하세요.

흔한 함정

  • LoadTables는 필수입니다. 어떤 스탯·도전 과제 연산보다 먼저 호출하세요. 빈 서비스에서의 AddToStat은 경고를 로그하지만 크래시하지는 않습니다.
  • 해금은 기본적으로 즉시 디스크에 씁니다. FlushOnUnlock 옵션의 기본값이 true라서 해금 하나마다 Save/Load 서비스를 통한 동기 저장이 수행됩니다. 도전 과제 여러 개가 한꺼번에 해금될 수 있다면 AchievementOptions에서 FlushOnUnlock = false로 설정하고 체크포인트에서 직접 Save()를 호출하세요.
  • 임계값 검사는 즉시 일어납니다. AddToStat이나 SetStat이 실행되는 순간 바인딩된 도전 과제가 재평가됩니다. "나중에 진행을 확인"하는 지연은 없습니다 — 조건이 충족되는 즉시 이벤트가 발생합니다.
  • 해금은 단방향입니다. 한 번 해금된 도전 과제는 해금 상태로 남습니다. (AllowNegativeDeltas 옵션을 켜서) 스탯이 나중에 감소하더라도 도전 과제가 다시 잠기지 않습니다.
  • 진행 이벤트는 옵트인입니다. 테이블의 도전 과제 정의에 PublishProgress = true를 설정해야 AchievementProgressChanged 이벤트가 활성화됩니다. playtime_seconds 같은 고빈도 스탯의 이벤트 스팸을 피하기 위해 기본값은 꺼짐입니다.
  • 커스텀 타입은 IL2CPP에서 link.xml이 필요합니다. 모듈 자체의 public 타입(예: AchievementSaveData)은 이미 보존됩니다. 플레이어 빌드에서 JsonUtility로 자체 타입을 직렬화한다면 IL2CPP 빌드가 스트리핑하지 않도록 프로젝트의 link.xml에 항목을 추가하세요.

관련 페이지

  • Save / LoadSave()Load() 뒤의 저장 슬롯과 원자적 쓰기
  • Event BusAchievementUnlockedAchievementProgressChanged 이벤트