도전 과제 & 스탯
플레이어 스탯을 추적하고, 임계값에서 도전 과제를 자동 해금하고, 진행 상황을 디스크에 저장하고, 해금 이벤트를 발행합니다.
도전 과제를 데이터 테이블로 선언하면, 추적·해금·저장은 프레임워크가 맡습니다.
| 인터페이스 | 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이며, GetStat은 0L을, IsUnlocked는 false를 반환하고, 디스크 쓰기도 이벤트 발행도 일어나지 않습니다. 도전 과제 없이 테스트하거나 최소 빌드를 출시할 때 사용하세요.
흔한 함정
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 / Load —
Save()와Load()뒤의 저장 슬롯과 원자적 쓰기 - Event Bus —
AchievementUnlocked와AchievementProgressChanged이벤트