GS2-Experience
Game as a Service 에서 성장 요소는 필수 불가결합니다. 캐릭터를 성장시키고, 성장한 캐릭터로 더욱 높은 난이도의 콘텐츠에 도전하는 게임 사이클을 구현하기 위한 경험치·랭크 기능을 제공합니다.
GS2-Experience 는 단일 캐릭터의 레벨뿐만 아니라, 여러 프로퍼티(캐릭터, 무기, 파티, 길드 등)의 성장을 하나의 마이크로서비스로 일원 관리할 수 있는 유연한 설계로 되어 있습니다.
유스케이스
GS2-Experience 가 상정하는 대표적인 유스케이스는 다음과 같습니다.
- 플레이어 캐릭터의 레벨 관리
- 육성 가능한 장비품의 레벨 관리
- 길드나 팀의 랭크 관리
- 칭호·배틀 패스의 레벨 관리
- 스킬 트리의 각 스킬 숙련도 관리
graph LR Player[플레이어] -->|경험치 가산| Exp[GS2-Experience] Exp -->|랭크 계산| Status[Status] Status -->|임계값 초과| RankUp[랭크업] RankUp -->|보상 지급| Inventory[GS2-Inventory] RankUp -->|스태미나 최대값 갱신| Stamina[GS2-Stamina]
랭크
GS2-Experience 는 경험치 값으로부터 자동으로 랭크 값을 계산합니다. 이를 위해서는 마스터 데이터를 이용해 랭크업 임계값이 되는 경험치 테이블을 정의해야 합니다. Experience Model 에서 랭크업 임계값을 관리하며, 그 아래에 임의의 값을 가지는 프로퍼티ID 를 종속시킬 수 있습니다. 프로퍼티ID 별로 경험치 값이 관리되고, 경험치 값에 따라 랭크가 결정됩니다.
Experience Model 과 프로퍼티ID 의 관계
graph TD N[Namespace] --> EM1["ExperienceModel<br/>character_ssr"] N --> EM2["ExperienceModel<br/>character_sr"] N --> EM3["ExperienceModel<br/>weapon"] EM1 --> P11["프로퍼티ID:<br/>character-001"] EM1 --> P12["프로퍼티ID:<br/>character-002"] EM3 --> P31["프로퍼티ID:<br/>weapon-001"] EM3 --> P32["프로퍼티ID:<br/>weapon-002"]
예를 들어 “캐릭터 종류별로 필요 경험치 테이블을 다르게 하고 싶다”, “캐릭터별로 랭크 캡을 다르게 하고 싶다” 와 같은 경우, ExperienceModel 을 캐릭터 종류별로 준비하고 프로퍼티ID 에 캐릭터의 개별 ID를 할당함으로써 구현할 수 있습니다.
랭크 캡
랭크에는 상한을 설정할 수 있습니다. 랭크의 상한에 도달한 상태에서 경험치를 입수한 경우, 경험치는 파기됩니다.
랭크의 상한은 Experience Model 에서 초기값을 설정하지만, 프로퍼티ID 별로 개별적으로 인상할 수 있습니다. 이를 통해 한계돌파 강화를 실시하면 랭크 캡을 인상하는 것과 같은 사양을 구현할 수 있습니다.
| 설정 항목 | 설명 |
|---|---|
defaultExperience | 초기 경험치 |
defaultRankCap | 초기 랭크 캡 |
maxRankCap | 랭크 캡의 상한값(이를 초과하는 SetRankCap 은 거부됨) |
rankThreshold | 랭크업에 필요한 경험치 임계값 배열 |
스테이터스 정보
Status 는 사용자 × ExperienceModel × 프로퍼티ID 의 조합으로 고유하게 결정되며, 다음 정보를 보유합니다.
| 속성 | 설명 |
|---|---|
experienceValue | 현재 경험치 |
rankValue | 현재 랭크 |
rankCapValue | 현재 랭크 캡 |
nextRankUpExperienceValue | 다음 랭크업에 필요한 경험치 |
nextRankUpExperienceValue 를 클라이언트에서 참조함으로써, 랭크업까지 필요한 경험치 표시나 진행 바 그리기를 간단하게 수행할 수 있습니다.
보상 가산 테이블
Experience Model 에는 랭크에 따라 보상량을 조정하는 acquireActionRates 를 설정할 수 있습니다.
배율에는 일반적인 rates 외에도, int64 를 초과하는 값을 다룰 수 있는 bigRates 도 정의할 수 있어, 인플레이션이 심한 게임에서도 정밀도를 유지한 채 보상량을 제어할 수 있습니다.
예를 들어, “랭크가 높은 플레이어에게는 고난도 퀘스트의 보상을 더 많이 지급한다” 와 같은 조정에 활용할 수 있습니다. 퀘스트 보상으로 랭크에 따른 배율을 입수 액션에 반영하고 싶은 경우, GS2-Quest 의 클리어 보상에 “랭크에 따른 보상 배율 적용” 을 트랜잭션 액션으로 포함시킵니다.
스크립트 트리거
네임스페이스에 각종 스크립트를 설정하면, 경험치 변화·랭크 변화·랭크 캡 변화의 전후에 커스텀 스크립트를 호출할 수 있습니다. 스크립트는 동기·비동기 실행 방식을 선택할 수 있으며, 비동기의 경우 GS2-Script 나 Amazon EventBridge 를 통한 외부 처리에도 대응합니다.
설정할 수 있는 주요 이벤트 트리거와 스크립트 설정명은 다음과 같습니다.
changeExperienceScript(완료 알림:changeExperienceDone): 경험치 변화 전후changeRankScript(완료 알림:changeRankDone): 랭크 변화 전후changeRankCapScript(완료 알림:changeRankCapDone): 랭크 캡 변경 전후
또한 다음 필드에는 스크립트의 GRN 을 직접 지정할 수 있으며, 해당 이벤트가 발생했을 때 스크립트가 호출됩니다.
rankCapScriptId: 랭크 캡 취득 시의 처리overflowExperienceScript: 랭크 캡 도달 후 잉여 경험치(넘친 경험치)의 처리
overflowExperienceScript 를 이용하면, 랭크 캡에 도달한 이후의 잉여 경험치를, 예를 들어 게임 내 통화로 GS2-Inventory 에 변환하는 처리를 자동화할 수 있습니다.
마스터 데이터 운용
마스터 데이터를 등록함으로써 마이크로서비스에서 이용 가능한 데이터나 동작을 설정할 수 있습니다.
마스터 데이터의 종류에는 다음이 있습니다.
ExperienceModel: 랭크 임계값과 보상 가산 테이블
마스터 데이터의 등록은 매니지먼트 콘솔에서 등록하는 것 외에도, GitHub 에서 데이터를 반영하거나, GS2-Deploy 를 사용해 CI 에서 등록하는 등의 워크플로우를 구성하는 것이 가능합니다.
ExperienceModel 의 JSON 예
{
"version": "2019-09-04",
"experienceModels": [
{
"name": "character_ssr",
"metadata": "SSR 캐릭터",
"defaultExperience": 0,
"defaultRankCap": 50,
"maxRankCap": 99,
"rankThreshold": {
"metadata": "SSR 랭크 테이블",
"values": [
100, 300, 600, 1000, 1500
]
},
"acquireActionRates": [
{
"name": "boost",
"mode": "double",
"rates": [1.0, 1.1, 1.2, 1.3, 1.4]
}
]
}
]
}버프에 의한 보정
GS2-Buff 와 연동하면, 스테이터스의 rankCapValue 나 AddExperienceByUserId·SubExperienceByUserId 등 액션의
experienceValue 를 버프로 보정할 수 있습니다. 이벤트나 캠페인 시에 획득량이나 랭크 상한을 일시적으로 증감시키는 등의 조정을 유연하게 할 수 있습니다.
대표적인 버프 대상은 다음과 같습니다.
| 종별 | 대상 | 보정 가능한 값 |
|---|---|---|
| 모델 | Status | rankCapValue |
| 액션 | AddExperienceByUserId | experienceValue |
| 액션 | SubExperienceByUserId | experienceValue |
| 액션 | AddRankCapByUserId | rankCapValue |
트랜잭션 액션
GS2-Experience 에서는 다음과 같은 트랜잭션 액션을 제공하고 있습니다.
- 검증 액션: 랭크 검증, 랭크 캡 검증
- 소비 액션: 경험치 감산, 랭크 캡 감산
- 입수 액션: 경험치 가산, 경험치 설정, 랭크 캡 가산, 랭크 캡 설정, 랭크에 따른 보상 배율 적용
“경험치 가산” 을 입수 액션으로 이용함으로써, 상점에서 상품 구매 시나 미션 달성 시의 보상으로 직접 캐릭터의 경험치를 늘려 랭크업시키는 처리가 가능해집니다. 또한 “랭크 캡 가산” 을 보상으로 설정함으로써, 특정 조건을 달성했을 때 자동으로 레벨 상한을 해제하는 운용도 가능합니다.
“랭크 검증” 을 검증 액션으로 이용함으로써, “랭크 10 이상의 캐릭터만 도전할 수 있는 퀘스트” 나 “랭크 50 이상에서 교환 가능한 강화 소재” 와 같은 게임 사양을 마스터 데이터 설정만으로 구현할 수 있습니다.
구현 예제
경험치 가산
경험치 가산은 게임 엔진용 SDK 로는 처리할 수 없습니다.
GS2-Quest 의 퀘스트 클리어 보상이나, GS2-Enhance 의 강화 보상으로 경험치를 가산하도록 해주세요.
랭크 캡 인상
랭크 캡 인상은 게임 엔진용 SDK 로는 처리할 수 없습니다.
GS2-Exchange 에서 강화 소재나, 동일 캐릭터와의 교환 보상으로 랭크 캡을 인상하도록 해주세요.
경험치 목록 취득
플레이어가 보유한 모든 Status 를 일괄로 취득합니다. 홈 화면의 스테이터스 표시나 캐릭터 목록 화면에서 활용할 수 있습니다.
var items = await gs2.Experience.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).StatusesAsync(
).ToListAsync(); const auto Domain = Gs2->Experience->Namespace(
"namespace-0001" // namespaceName
)->Me(
AccessToken
);
const auto It = Domain->Statuses(
);
TArray<Gs2::UE5::Experience::Model::FEzStatusPtr> Result;
for (auto Item : *It)
{
if (Item.IsError())
{
return false;
}
Result.Add(Item.Current());
}var iterator = ez.experience.namespace_(
"namespace-0001"
).me(
game_session
).statuses(
)
var async_result = await iterator.load()
if async_result.error != null:
# 오류 처리
push_error(str(async_result.error))
return
var items = async_result.result경험치 취득
var item = await gs2.Experience.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).Status(
experienceName: "character_ssr",
propertyId: "property-0001"
).ModelAsync(); const auto Domain = Gs2->Experience->Namespace(
"namespace-0001" // namespaceName
)->Me(
AccessToken
)->Status(
"character_ssr", // experienceName
"property-0001" // propertyId
);
const auto Future = Domain->Model();
Future->StartSynchronousTask();
if (Future->GetTask().IsError()) return false;
const auto Item = Future->GetTask().Result();var domain = ez.experience.namespace_(
"namespace-0001"
).me(game_session).status(
"character_ssr",
"property-0001"
)
var async_result = await domain.model()
if async_result.error != null:
push_error(str(async_result.error))
return
var result = async_result.resultExperienceModel 목록 취득
클라이언트에서 랭크업에 필요한 경험치 테이블을 참조하고자 할 때 이용합니다.
var items = await gs2.Experience.Namespace(
namespaceName: "namespace-0001"
).ExperienceModelsAsync(
).ToListAsync(); const auto It = Gs2->Experience->Namespace(
"namespace-0001" // namespaceName
)->ExperienceModels();
TArray<Gs2::UE5::Experience::Model::FEzExperienceModelPtr> Result;
for (auto Item : *It)
{
if (Item.IsError())
{
return false;
}
Result.Add(Item.Current());
}var iterator = ez.experience.namespace_(
"namespace-0001"
).experience_models(
)
var async_result = await iterator.load()
if async_result.error != null:
# 오류 처리
push_error(str(async_result.error))
return
var items = async_result.result특정 ExperienceModel 취득
var item = await gs2.Experience.Namespace(
namespaceName: "namespace-0001"
).ExperienceModel(
experienceName: "character_ssr"
).ModelAsync(); const auto Domain = Gs2->Experience->Namespace(
"namespace-0001" // namespaceName
)->ExperienceModel(
"character_ssr" // experienceName
);
const auto Future = Domain->Model();
Future->StartSynchronousTask();
if (Future->GetTask().IsError()) return false;
const auto Item = Future->GetTask().Result();var domain = ez.experience.namespace_(
"namespace-0001"
).experience_model(
"experience-0001"
)
var async_result = await domain.model()
if async_result.error != null:
push_error(str(async_result.error))
return
var result = async_result.result소지 증명 서명 취득
GS2 내의 다른 마이크로서비스와 연동할 때, 실제로 GS2-Experience 에서 랭크나 경험치가 올바른지 보증하는 데이터가 요구되는 경우가 있습니다.
예를 들어, GS2-Experience 에서 플레이어의 랭크를 관리하고 있고, GS2-Stamina 에서 그 랭크에 따라 스태미나의 최대값을 결정한다고 합시다. GS2-Stamina 는 소지 증명 서명과 함께 랭크를 지정하도록 요구합니다.
이를 통해, GS2-Stamina 는 뒤에서 GS2-Experience 와 통신하여 실제로 해당 랭크에 도달했는지를 판단할 필요가 없어집니다.
sequenceDiagram participant Player as 플레이어 participant Exp as GS2-Experience participant Other as 다른 마이크로서비스 Player->>Exp: GetStatusWithSignature Exp-->>Player: Body + Signature Player->>Other: API + Body + Signature Other->>Other: 서명 검증 Other-->>Player: 처리 결과
var result = await gs2.Experience.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).Status(
experienceName: "character_ssr",
propertyId: "property-0001"
).GetStatusWithSignatureAsync(
keyId: "grn:gs2:{region}:{yourOwnerId}:key:namespace-0001:key:key-0001"
);
var body = result.Body;
var signature = result.Signature; const auto Domain = Gs2->Experience->Namespace(
"namespace-0001" // namespaceName
)->Me(
AccessToken
)->Status(
"character_ssr", // experienceName
"property-0001" // propertyId
);
const auto Future = Domain->GetStatusWithSignature(
"grn:gs2:{region}:{yourOwnerId}:key:namespace-0001:key:key-0001" // keyId
);
Future->StartSynchronousTask();
if (Future->GetTask().IsError()) return false;
// obtain changed values / result values
const auto Future2 = Future->GetTask().Result()->Model();
Future2->StartSynchronousTask();
if (Future2->GetTask().IsError()) return false;
const auto Result = Future2->GetTask().Result();
const auto Body = Result->Body;
const auto Signature = Result->Signature;var domain = ez.experience.namespace_(
"namespace-0001"
).me(game_session).status(
"character_ssr",
"property-0001"
)
var async_result = await domain.get_status_with_signature(
"key-0001" # key_id
)
if async_result.error != null:
push_error(str(async_result.error))
return
var result = async_result.result