Home 메이플스토리 월드 | 랭킹 시스템 구현하기
Post
Cancel

메이플스토리 월드 | 랭킹 시스템 구현하기

랭킹 시스템은 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 데이터 추가 및 갱신DB 데이터 추가 및 갱신

랭킹 리더보드 UI 생성

랭킹 시스템을 구현하기 위해 첫 번째로 해야 할 일은 랭킹을 표시할 랭킹 리더보드 UI를 만드는 것입니다. 저는 대기 화면에서 유저들이 랭킹을 확인할 수 있도록 다음과 같이 랭킹 리더보드 UI(Preset List-UI-랭킹)를 DefaultGroup에 배치했습니다.

랭킹 리더보드 UI랭킹 리더보드 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를 업데이트하는 코드를 추가해 봅시다.

최고 기록 프로퍼티 추가

유저의 최고 기록을 저장하기 위해 DefaultPlayerPlayerStats 컴포넌트number 자료형인 BestScore를 추가합니다.

1
2
3
Property:
    [None]
    number BestScore = 0

DB 갱신

DB를 갱신하는 함수 SetDBCardPairGameLogic에 추가합니다.

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에서 데이터를 요청하는 함수 GetDBCardPairGameLogic에 추가합니다.

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를 업데이트하는 함수 UpdateLeaderboardCardPairGameLogic에 추가합니다.

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로 구현하시는 것을 추천합니다:)

This post is licensed under CC BY 4.0 by the author.

메이플스토리 월드 | 배지 추가하기

-