랭킹 시스템은 RPG뿐만 아니라 MOBA, RTS 등 다양한 장르에서 사용되며, 게임을 플레이하는 근본적인 목적을 유저들에게 부여합니다. 이번 포스팅에서는 메이플스토리 월드에서 랭킹 시스템을 구현해 보겠습니다.
실습 월드는 카드 짝 맞추기 리메이크이며 코드는 공식 문서와 크리에이터 포럼을 참조하여 작성했습니다.
랭킹 시스템을 구현하기 위해서는 유저들의 순위를 매길 게임 데이터를 저장해야 합니다. 하지만 클라이언트 내 게임 데이터는 유저가 게임을 종료할 시 사라지기 때문에 게임 데이터를 유실하지 않도록 데이터베이스(DB)를 사용해야 합니다. 메이플스토리 월드 API는 다음과 같이 DB의 역할을 하는 4가지의 DataStorage를 제공합니다.
GlobalDataStorage
(공식 문서) : 하나의 월드에서 사용되는 데이터 스토리지로 다른 월드와 데이터가 공유되지 않고, string 타입의 값만 사용할 수 있습니다.UserDataStorage
(공식 문서) : 유저당 하나의 UserDataStorage를 가질 수 있습니다. 하나의 월드에서 사용되는 데이터 스토리지로 다른 월드와 데이터가 공유되지 않고, string 타입의 값만 사용할 수 있습니다.CreatorDataStorage
(공식 문서) : 크리에이터당 하나의 CreatorDataStorage를 가질 수 있습니다. 다른 월드와 데이터를 공유하며, string 타입의 값만 사용할 수 있습니다.SortableDataStorage
(공식 문서) : 하나의 월드에서 사용되는 데이터 스토리지로 다른 월드와 데이터가 공유되지 않고, int 타입의 값만 사용할 수 있습니다.
게임의 구조에 따라 다양한 방식으로 랭킹 시스템을 구현할 수 있겠지만 후술할 테이블 형태의 데이터 관리 방식이 데이터 추가 및 갱신에 용이하다고 판단하여 GlobalDataStorage
를 사용하여 카드 짝 맞추기 리메이크의 랭킹 시스템을 구현해 보겠습니다.
GlobalDataStorage와 테이블을 활용한 데이터 추가 및 갱신
Lua의 테이블은 배열과 딕셔너리의 특징을 모두 가지는 자료구조입니다. 또한, Lua의 테이블은 메이플스토리 월드 API에서 제공하는 UtilLogic
(공식 문서)을 통해 string 타입으로 변환이 가능합니다. 따라서 이러한 특성을 활용하여 string 값만 저장할 수 있는 GlobalDataStorage
에 다음과 같이 데이터를 추가 및 갱신할 수 있습니다.
DB 데이터 추가 및 갱신
랭킹 리더보드 UI 생성
랭킹 시스템을 구현하기 위해 첫 번째로 해야 할 일은 랭킹을 표시할 랭킹 리더보드 UI를 만드는 것입니다. 저는 대기 화면에서 유저들이 랭킹을 확인할 수 있도록 다음과 같이 랭킹 리더보드 UI(Preset List
-UI
-랭킹
)를 DefaultGroup
에 배치했습니다.
랭킹 리더보드 UI
랭킹 UI Preset을 추가하면 컴포넌트 UILeaderboard
가 추가됩니다. 메이커를 통해 확인해 보면 해당 컴포넌트는 현재 접속 중인 유저들의 x 좌표를 점수로 순위를 매겨 리더보드를 1초마다 갱신하는 스크립트입니다. 따라서 접속하지 않은 유저들의 게임 데이터도 리더보드에 표시할 수 있도록 스크립트를 수정해 보겠습니다.
랭킹 리더보드 UI 초기화
게임 시작 시 랭킹 리더보드 UI를 초기화하는 스크립트입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Property:
[None]
Entity item = nil
[None]
table itemTable = {}
[None]
TextComponent myRank = nil
[None]
TextComponent myName = nil
[None]
TextComponent myScore = nil
Method:
[client only]
void OnBeginPlay ()
{
local currentPath = self.Entity.Path
local me = _UserService.LocalPlayer
self.item = _EntityService:GetEntityByPath(currentPath .."/ScrollLayout/Item")
self.item.Enable = false -- 랭킹 아이템 템플릿 비활성화
self.myRank = _EntityService:GetEntityByPath(currentPath.."/My_rank/Text_rank").TextComponent
self.myName = _EntityService:GetEntityByPath(currentPath.."/My_rank/Text_name").TextComponent
self.myScore = _EntityService:GetEntityByPath(currentPath.."/My_rank/Text_score").TextComponent
-- 기록 초기화
self.myRank.Text = "-"
self.myName.Text = me.PlayerComponent.Nickname.."#"..me.PlayerComponent.ProfileCode
self.myScore.Text = "0"
}
최고 기록 달성 시 DB 갱신 및 랭킹 리더보드 UI 업데이트
다음은 최고 기록 달성 시 DB를 갱신하고 랭킹 리더보드 UI를 업데이트하는 코드를 추가해 봅시다.
최고 기록 프로퍼티 추가
유저의 최고 기록을 저장하기 위해 DefaultPlayer
의 PlayerStats
컴포넌트에 number 자료형인 BestScore
를 추가합니다.
1
2
3
Property:
[None]
number BestScore = 0
DB 갱신
DB를 갱신하는 함수 SetDB
를 CardPairGameLogic
에 추가합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
[server]
void SetDB(Entity me, number BestScore)
{
local ds = _DataStorageService:GetGlobalDataStorage("Rank")
local nickName = me.PlayerComponent.Nickname
local profileCode = me.PlayerComponent.ProfileCode
local fullName = nickName.."#"..profileCode
local callback = function(errorcode, key, value) -- callback 함수
local itemTable = {}
if value ~= nil then
itemTable = _UtilLogic:StringToTable(value) -- 테이블 변환
end
itemTable[fullName] = bestScore -- 데이터 추가 및 갱신
self:UpdateLeaderboard(itemTable) -- 랭킹 리더보드 UI 업데이트
local temp = {}
for k, v in pairs(itemTable) do
temp[#temp + 1] = "String "..k.." Double "..v
end
local str = table.concat(temp, "\n") -- String 변환
ds:SetAsync("Record", str, nil) -- DB에 데이터 저장
end
ds:GetAsync("Record", callback) -- DB에서 데이터 불러오기 / callback 함수 호출
}
DB에서 데이터 요청
DB에서 데이터를 요청하는 함수 GetDB
를 CardPairGameLogic
에 추가합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[server]
void GetDB(Entity me)
{
local ds = _DataStorageService:GetGlobalDataStorage("Rank")
local nickName = me.PlayerComponent.Nickname
local profileCode = me.PlayerComponent.ProfileCode
local fullName = nickName.."#"..profileCode
local callback = function(errorcode, key, value) -- callback 함수
if value == nil then
return
end
local itemTable = _UtilLogic:StringToTable(value) -- 테이블 변환
self:UpdateLeaderboard(itemTable) -- 랭킹 리더보드 UI 업데이트
end
ds:GetAsync("Record", callback) -- DB에서 데이터 불러오기 / callback 함수 호출
}
랭킹 리더보드 UI 업데이트
랭킹 리더보드 UI를 업데이트하는 함수 UpdateLeaderboard
를 CardPairGameLogic
에 추가합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
[client]
void UpdateLeaderboard(table ItemTable)
{
local leaderboard = self.Leaderboard.UILeaderboard
local me = _UserService.LocalPlayer
local nickName = me.PlayerComponent.Nickname
local profileCode = me.PlayerComponent.ProfileCode
local fullName = nickName.."#"..profileCode
local sortedTable = {}
-- 기존 랭킹 아이템 삭제
if #leaderboard.itemTable ~= 0 then
for i = 1, #leaderboard.itemTable do
_EntityService:Destroy(leaderboard.itemTable[i])
end
end
for k, v in pairs(ItemTable) do
local tempTable = {}
tempTable["Name"] = k
tempTable["Record"] = v
table.insert(sortedTable, tempTable)
end
table.sort(sortedTable,function(a, b) return a["Record"] > b["Record"] end) -- 테이블을 기록이 높은 순으로 정렬
local index = 1
local temp = 0 -- 이전 랭킹 아이템의 기록
local tempIndex = 1
for _, v in pairs(sortedTable) do
leaderboard.itemTable[index] = leaderboard.item:Clone("Item"..index) -- 랭킹 아이템 템플릿 복제
local currentItem = leaderboard.itemTable[index]
currentItem.Enable = true -- 현재 랭킹 아이템 활성화
local textRank = _EntityService:GetEntityByPath(currentItem.Path .."/Text_rank")
local textId = _EntityService:GetEntityByPath(currentItem.Path .."/Text_name")
local textScore = _EntityService:GetEntityByPath(currentItem.Path .."/Text_score")
-- 이전 랭킹 아이템과 기록이 동일할 시 이전 랭킹 아이템과 현재 랭킹 아이템의 순위를 같게 함
if v["Record"] ~= temp then
tempIndex = index
end
textRank.TextComponent.Text = tostring(tempIndex)
textId.TextComponent.Text = v["Name"]
textScore.TextComponent.Text = string.format("%d", tostring(v["Record"]))
-- 본인의 최고 기록 업데이트
if v["Name"] == fullName then
me.PlayerStats.BestScore = v["Record"]
leaderboard.myRank.Text = textRank.TextComponent.Text
leaderboard.myName.Text = v["Name"]
leaderboard.myScore.Text = textScore.TextComponent.Text
end
temp = v["Record"]
index = index + 1
end
}
최고 기록 달성 확인
CardPairGameLogic
의 함수 OnUpdate
에 게임 종료 후 최고 기록 달성 여부에 따라 DB를 갱신하는 코드를 추가합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
[client only]
void OnUpdate(number delta)
{
self.RemainingTime = self.RemainingTime - delta
local me = _UserService.LocalPlayer
if self.RemainingTime <= 0 then
if me.PlayerStats.Score > me.PlayerStats.BestScore then -- 최고 기록 달성
me.PlayerStats.BestScore = me.PlayerStats.Score
self:SetDB(me, me.PlayerStats.BestScore) -- DB 갱신
end
self:EndGame()
end
}
게임 시작 시 본인 최고 기록 및 랭킹 리더보드 UI 업데이트
CardPairGameLogic
의 함수 OnBeginPlay
에 게임 시작 시 본인의 최고 기록을 DB에서 가져오고 랭킹 리더보드 UI를 업데이트하는 코드를 추가합니다.
1
2
3
4
5
6
7
8
[client only]
void OnBeginPlay()
{
local me = _UserService.LocalPlayer
self:StartGame()
self:GetDB(me) -- DB에서 데이터 요청
}
실습 결과
코드 작성을 완료했다면 메이커에서 게임을 실행하고 가상 인물을 추가하여 다음과 같이 랭킹 리더보드 UI가 올바르게 작동하는지 확인해 봅시다.
p.s. 크리에이터 포럼에 따르면 위에서 구현한 랭킹 시스템은 한 가지 key만 사용하여 사용량 제한에 걸릴 우려가 있고, 복수 서버가 할당되었을 때 마지막으로 DB에 데이터를 덮어씌운 서버를 제외하고 데이터가 유실될 수 있다고 합니다.
p.p.s. 랭킹 시스템과 직관적으로 가장 잘 어울리는 SortableDataStorage
를 사용하지 않은 이유는 value에 int 값만 저장할 수 있어 데이터의 id 역할을 하는 key 값을 따로 저장해야 하는 이중 작업을 해야 한다고 생각했기 때문입니다. 하지만 포스트를 작성하기 위해 공식 문서를 자세히 읽어봤는데 SortableDataStorage
는 key 값을 입력하지 않고도 데이터 스토리지 내의 모든 데이터를 정렬된 순서로 조회할 수 있는 것을 확인했습니다. 따라서 이후에 랭킹 시스템을 구현하실 분들은 제 코드는 참조만 하시고, SortableDataStorage
로 구현하시는 것을 추천합니다:)