이세계에 진입한 서버 개발 - 5

Feb 9, 2017 • nodejs azure webapps webservice


들어가는 말

퍼즐 게임이 아니라면 게임 내에서 아이템은 그 종류가 정말 다양하다.

요즘은 퍼즐 게임도 특수 아이템을 제공하거나 구매할 수 있는 것도 많다.

게임 클라이언트에서는 특수 능력을 발휘하거나 체력을 채워줄 수도 있고, 9강 검이 될 수도 있다.

하지만 게임 서버안에서는 보유와 강화 등의 정보만 기록하면 된다.

MORPG 게임 서버를 만드는게 아니니까 다른건 모른다(?)

이번 강좌에서는 아이템 보유와 강화, 승급 기능을 제작해도보도록 하겠다.

모델 추가

기본이 되는 것은 아래 2개이다. 아이템 정의와 아이템 보유 모델.

models 폴더에 파일을 추가하고 각각 아래 내용을 적용한다.

그리고 부수적으로 아래 4개의 모델을 추가한다.

강화와 승급을 설명할 때 추가하려고 했으나 미리 추가해놓는게 편해서 일단 모두 등록한다.

각각 클릭해서 models 폴더에 추가한다.

관계 설명

와 이렇게 뭐 없이 설명하니까 되게 좋네!!

오예

너무 설명이 없는 듯하니까 각 모델간의 관계를 설명해보겠다.

아이템다이어그램

추가 모델을 제거하면 기본은 OwnItemDefineItem이다.

이 중 DefineItem은 강화와 승급을 위해 ReinforceItemIDUpgradeItemID를 가질 수 있다.

각각의 외래키는 Define__RequireItem으로 이어지고 여기서는 1개의 __ItemID로 다수의 아이템이 필요하다는 정보를 저장하게 된다.

왜 이런 구조를 가지냐면 어떤 경우는 골드만 사용하고 싶을 수 있지만 어떤 경우는 골드와 특정 아이템을 함께 소모해서 다음으로 넘기기 위한 방법이다.

다만 이런 처리로는 경험치를 쌓아서 강화하는 형태는 불가능하다.

이런 구조를 원한다면 별도의 테이블을 구상해야할 것이다.

좋은 고민거리죠?!

라우터 추가

routes폴더에 item.js파일을 추가하고 아래 내용을 적용한다.

app.js에 등록

app.js 파일을 수정하여 item 라우터를 추가해보자.

  1. app.js에서 아래 내용을 찾아서 그 아래쪽에 코드를 추가한다.

    • 찾아야하는 내용

        const routes = require('./routes/index');
      
    • 추가할 코드

        const item = require('./routes/item');
      
  2. app.js에서 아래 내용을 찾아서 그 아래쪽에 코드를 추가한다.

    • 찾아야하는 내용

        app.use('/', routes);
      
    • 추가할 코드

        app.use('/item', item);
      

몇번 해본 작업이라서 손이 알아서 처리하고 있을것이다.

로직 작성

로직은 총 6개를 추가할 것인데 그중 4개는 정보를 요청하는 것이고 2개는 강화, 승급을 각각 처리하는 것이다.

통화를 기억해보면 단순히 정보를 요청하는 코드는 단순하다

routes/item.js에 다음 코드를 추가한다.

자주 해봐서 알겠지만 modules.export = router; 위쪽에 추가하면 된다.

보유 아이템 목록 요청

/**
 * @api {GET} /item/own
 * @apiName 보유 아이템 목록 요청
 * @apiHeader {String} Authorization JWT토큰을 전송
 */
router.get('/own', auth.isAuthenticated, (req, res, next) => {
    models.OwnItem.findAll({
        where: { GameUserID: req.user.GameUserID }
    })
    .then((ownItemList) => {
        res.send({ result: 0, list: ownItemList });
    })
    .catch((err) => {
        next(err);
    });
})


아이템 정보 목록 요청

/**
 * @api {GET} /item/define
 * @apiName 정의된 아이템 정보 요청
 * @apiHeader {String} Authorization JWT토큰을 전송
 */
router.get('/define', auth.isAuthenticated, (req, res, next)=>{
    models.DefineItem.findAll()
    .then((DefineItemList) => {
        res.send({ result: 0, list: DefineItemList });
    })
    .catch((err) => {
        next(err);
    });
})


강화 관련 데이터 요청


/**
 * @api {GET} /item/define/reinforce
 * @apiName 강화 관련 아이템 데이터 조회
 * @apiHeader {String} Authorization JWT토큰을 전송
 */
router.get('/define/reinforce', auth.isAuthenticated, (req, res, next)=>{
    models.DefineReinforceItem.findAll({
        include: [{
            model: models.DefineReinforceRequireItem,
            as: 'LevelInfo'
        }]
    })
    .then((defineReinforceItem)=>{
        res.send({result:0, list:defineReinforceItem});
    })
})


승급 관련 데이터 요청

/**
 * @api {GET} /item/define/upgrade
 * @apiName 승급 관련 아이템 데이터 조회
 * @apiHeader {String} Authorization JWT토큰을 전송
 */
router.get('/define/upgrade', auth.isAuthenticated, (req, res, next)=>{
    models.DefineUpgradeItem.findAll({
        include: [{
            model: models.DefineUpgradeRequireItem,
            as: 'UpgradeInfo'
        }]
    })
    .then((defineUpgradeItem)=>{
        res.send({result:0, list:defineUpgradeItem});
    })
})

정의된 데이터를 요청하는 것은 이처럼 간단하다.

강화 요청

강화와 승급의 로직은 기본은 같다.

Define__RequireItem에 등록된 ItemIDRequireQNTY만큼 보유했으면 아이템을 소모시키고 해당 레벨이나 등급을 올리면 된다.

공통 사용 로직 추가

자원 - 아이템, 통화를 통합하여 지칭 - 이 충분하지 체크하거나 소모시키는 일은 자주 일어날 수 있다.

이런 내용은 따로 관리하는게 편리하므로 logics폴더에 materialCtrl.js파일을 추가하고 아래 링크의 내용을 반영한다.

그리고 routes/item.js 파일에서 아래 부분을 찾아서 필요한 내용을 추가한다.

  • 찾아야하는 내용

        const wendyError = require('../utils/error');
    
  • 추가할 내용

        const materialCtrl = require('../logics/materialCtrl');
    

강화 요청 등록

앞서 말한 로직을 바탕으로 내용을 추가해보자.

먼저 사용할 메서드 3개를 등록한다.

  • LoadOwnItem
  • LoadDefineItem
  • DecoretorMaterials

2개 메서드는 보유 아이템과 아이템 정의 관련 데이터를 로딩할 때 사용된다.

DecoretorMaterials은 소모할 아이템 및 통화를 검증하거나 소모할 때 상용된다. fn 매개변수로 메서드를 전달하면 실행한다.

function LoadOwnItem(OwnItemUID, GameUserID) {
    //소유 아이템 조회
    return models.OwnItem.findOne(
        {where:
            {
                OwnItemUID: OwnItemUID, 
                GameUserID: GameUserID
            }
        })
    .then((ownItem)=>{
        //아이템이 존재하는지 체크
        if(ownItem === null || ownItem === undefined)
            throw wendyError('NotInOwnItemList');
        return Promise.resolve(ownItem);
    })
}

function LoadDefineItem(
    ItemID, 
    ColumnName='ReinforceItemID', 
    errType='CantReinfoceItem') {
    //아이템 정보 로딩
    return models.DefineItem.findOne({
                where:{
                    ItemID:ItemID
                }
            })
    .then((itemInfo)=>{
        if(itemInfo[ColumnName] === null)
            throw wendyError(errType);
        return Promise.resolve(itemInfo);
    })
}

/**
 * 소모할 아이템 및 통화를 fn에 따라서 검증 및 소모.
 * @param {function} fn 실행할 메서드
 * materialCtrl의 existEnoughMaterial, decrementMaterial을 사용
 */
function DecoretorMaterials(
    fn, 
    LoadInfos, GameUserID, 
    materialItemUID, materialCurrencyUID) {

    let promises = [];
    LoadInfos.forEach((value, key)=>{
        promises.push(
            fn(
                GameUserID,
                value.ItemID,
                value.RequireQNTY,
                value.DefineItem.ItemType===10
                    ?materialItemUID
                    :materialCurrencyUID)
        );
    })

    if(promises.length > 0)
        return Promise.all(promises);
    return Promise.resolve();
}


위 메서드를 활용해서 실제 로직을 추가한다.

/**
 * @api {POST} /item/reinforce/:OwnItemUID
 * @apiName 강화 요청
 * @apiHeader {String} Authorization JWT토큰을 전송
 * @apiParam {Array} materialItemUID 소모할 ItemUID
 * @apiParam {Array} materialCurrencyUID 소모할 CurrencyUID
 */
router.post('/reinforce/:OwnItemUID', auth.isAuthenticated, (req, res, next)=>{

    let checkRequestBody = commonFunc.ObjectExistThatKeys(
            req.body, 
            ['materialItemUID', 'materialCurrencyUID']);
    if(checkRequestBody === false) {
        throw wendyError('DontHaveRequiredParams');
    }

    let loadItems = { 
        targetItem : null,
        targetItemInfo : null,
        LoadInfos : new Map()
    };

    //보유 아이템 로딩
    LoadOwnItem(
        req.params.OwnItemUID, 
        req.user.GameUserID)
    .then((OwnItem)=>{
        loadItems.targetItem = OwnItem;
        return Promise.resolve();
    })
    //아이템 정보 로딩
    .then(()=>{
        return LoadDefineItem(
            loadItems.targetItem.ItemID
        );
    })
    .then((itemInfo)=>{
        loadItems.targetItemInfo = itemInfo;
        return Promise.resolve();
    })
    //강화 데이터 로딩.
    .then(()=>{
        return  models.DefineReinforceItem.findOne({
            where:{
                ReinforceItemID:
                    loadItems.targetItemInfo['ReinforceItemID']
            },
            include: [{
                model: models['DefineReinforceRequireItem'],
                as: 'LevelInfo',
                include: [{
                    model: models.DefineItem,
                    attributes : ['ItemType', 'Multiple']
                }]
            }]
        })
    })
    .then((reinfoceInfos)=>{

        //강화에 사용되는 아이템이 정의되었는가?
        if(reinfoceInfos.LevelInfo.length === 0) 
            throw wendyError('DidntRegisterReinfoceRequireItem');

        //Level로 정렬.
        reinfoceInfos.LevelInfo.sort((a,b)=>{
            return a.Level - b.Level;
        })

        //다음 레벨로 강화가 가능한지 확인?
        let possibleLevelUp = false;
        for(let row of reinfoceInfos.LevelInfo) {
            if(row['Level'] === loadItems.targetItem['Level']) {
                possibleLevelUp = true;
                loadItems.LoadInfos.set(row.ItemID, row);
            }
        }
        if(possibleLevelUp === false)
            throw wendyError('NoLongerReinforce')

        return Promise.resolve();
    })
    //아이템과 통화를 충분한 량 보유했는지 체크.
    .then(()=>{
        return DecoretorMaterials(
            materialCtrl.existEnoughMaterial,
            loadItems.LoadInfos,
            req.user.GameUserID,
            req.body.materialItemUID,
            req.body.materialCurrencyUID
        );
    })
    //아이템 및 통화, 삭제 혹은 차감처리
    .then(()=>{
        return DecoretorMaterials(
            materialCtrl.decrementMaterial,
            loadItems.LoadInfos,
            req.user.GameUserID,
            req.body.materialItemUID,
            req.body.materialCurrencyUID
        );
    })
    //강화로 레벨업 반영.
    .then(()=>{
        return models.OwnItem.update({
            Level:loadItems.targetItem['Level']+1,
            UpdateTimeStamp: new Date()
        },
        {where:{
            OwnItemUID:loadItems.targetItem.OwnItemUID
        }});
    })
    .then(()=>{
        res.send({result:0});
    })
    .catch((err)=>{
        next(err);
    })
})


  • 24~57번 줄 : 보유 아이템과 아이템 정보를 로딩한다.
  • 58~81번 줄 : 강화를 진행하기에 적합한지 데이터를 검증한다.
  • 83~101번 줄 : DecoretorMaterials 메서드에 사용할 전달하여 충분한 자원을 보유했는지 확인하고 각각 소모한다.
  • 103~110 줄 : 레벨을 증가시켜 기록한다.

승급 요청

앞서 살펴본 강화 요청과 승급은 거의 비슷하다.

다른점이라면 불러야하는 테이블이 다르고 에러 코드가 다르다는 것 정도다.

/**
 * @api {POST} /item/upgrade/:OwnItemUID
 * @apiName 승급 요청
 * @apiHeader {String} Authorization JWT토큰을 전송
 * @apiParam {Array} materialItemUID 소모할 ItemUID
 * @apiParam {Array} materialCurrencyUID 소모할 CurrencyUID
 */
router.post('/upgrade/:OwnItemUID', auth.isAuthenticated, (req, res, next)=>{

    let checkRequestBody = commonFunc.ObjectExistThatKeys(
            req.body, 
            ['materialItemUID', 'materialCurrencyUID']);
    if(checkRequestBody === false) {
        throw wendyError('DontHaveRequiredParams');
    }

    let loadItems = { 
        targetItem : null,
        targetItemInfo : null,
        LoadInfos : new Map()
    };

    //보유 아이템 로딩
    LoadOwnItem(
        req.params.OwnItemUID, 
        req.user.GameUserID)
    .then((OwnItem)=>{
        loadItems.targetItem = OwnItem;
        return Promise.resolve();
    })
    //아이템 정보 로딩
    .then(()=>{
        return LoadDefineItem(
            loadItems.targetItem.ItemID,
            'UpgradeItemID',
            'CantUpgradeItem'
        );
    })
    .then((itemInfo)=>{
        loadItems.targetItemInfo = itemInfo;
        return Promise.resolve();
    })
    //승급 데이터 로딩.
    .then(()=>{
        return  models.DefineUpgradeItem.findOne({
            where:{
                UpgradeItemID:
                    loadItems.targetItemInfo['UpgradeItemID']
            },
            include: [{
                model: models['DefineUpgradeRequireItem'],
                as: 'UpgradeInfo',
                include: [{
                    model: models.DefineItem,
                    attributes : ['ItemType', 'Multiple']
                }]
            }]
        })
    })
    .then((upgradeInfos)=>{

        //승급에 사용되는 아이템이 정의되었는가?
        if(upgradeInfos.UpgradeInfo.length === 0) 
            throw wendyError('DidntRegisterUpgradeRequireItem');

        //Tier로 정렬.
        upgradeInfos.UpgradeInfo.sort((a,b)=>{
            return a.Tier - b.Tier;
        })

        //다음 Tier로 승급이 가능한지 확인?
        let possibleUpgrade = false;
        for(let row of upgradeInfos.UpgradeInfo) {
            if(row['Tier'] === loadItems.targetItem['Tier']) {
                possibleUpgrade = true;
                loadItems.LoadInfos.set(row.ItemID, row);
            }
        }
        if(possibleUpgrade === false)
            throw wendyError('NoLongerUpgrade')

        return Promise.resolve();
    })
    //아이템과 통화를 충분한 량 보유했는지 체크.
    .then(()=>{
        return DecoretorMaterials(
            materialCtrl.existEnoughMaterial,
            loadItems.LoadInfos,
            req.user.GameUserID,
            req.body.materialItemUID,
            req.body.materialCurrencyUID
        );
    })
    //아이템 및 통화, 삭제 혹은 차감처리
    .then(()=>{
        return DecoretorMaterials(
            materialCtrl.decrementMaterial,
            loadItems.LoadInfos,
            req.user.GameUserID,
            req.body.materialItemUID,
            req.body.materialCurrencyUID
        );
    })
    //승급으로 Tier 반영.
    .then(()=>{
        return models.OwnItem.update({
            Tier:loadItems.targetItem['Tier']+1,
            UpdateTimeStamp: new Date()
        },
        {where:{
            OwnItemUID:loadItems.targetItem.OwnItemUID
        }});
    })
    .then(()=>{
        res.send({result:0});
    })
    .catch((err)=>{
        next(err);
    })
})

에러코드 추가

아이템과 관련한 공통 에러가 3개, 강화와 승급에 각각 3개의 에러코드가 추가된다.

앞서 사용하도록 코드를 작성했으므로 utils/error.js에 필요한 에러코드를 추가하도록 한다.

먼저 아래 9개 클래스를 등록한다.

다른 클래스가 등록된 것을 참고하여 붙여넣으면 된다.

  • NotInOwnItemList
  • MaterialDoesNotExistOwnItemList
  • NotEnoughItem
  • CantReinfoceItem
  • DidntRegisterReinfoceRequireItem
  • NoLongerReinforce
  • CantUpgradeItem
  • DidntRegisterUpgradeRequireItem
  • NoLongerUpgrade
/** 80501 */
class NotInOwnItemList extends CustomError {
    constructor() {
        let message = '보유한 아이템 중에 해당 아이템이 없다.';
        let code = 80501;
        super(message, code);
    }
}

/** 80502 */
class MaterialDoesNotExistOwnItemList extends CustomError {
    constructor() {
        let message = '보유한 아이템 중에 재료로 사용할 아이템이 없다.';
        let code = 80502;
        super(message, code);
    }
}

/** 80503 */
class NotEnoughItem extends CustomError {
    constructor() {
        let message = '해당 아이템이 충분하지 않다.';
        let code = 80503;
        super(message, code);
    }
}

/** 80511 */
class CantReinfoceItem extends CustomError {
    constructor() {
        let message = '강화가 불가능한 아이템';
        let code = 80511;
        super(message, code);
    }
}

/** 80512 */
class DidntRegisterReinfoceRequireItem extends CustomError {
    constructor() {
        let message = '강화에 필요한 아이템이 등록하지 않았다';
        let code = 80512;
        super(message, code);
    }
}

/** 80513 */
class NoLongerReinforce extends CustomError {
    constructor() {
        let message = '더 이상 강화되지 않는다(최대레벨도달)';
        let code = 80513;
        super(message, code);
    }
}

/** 80521 */
class CantUpgradeItem extends CustomError {
    constructor() {
        let message = '승급이 불가능한 아이템';
        let code = 80521;
        super(message, code);
    }
}

/** 80522 */
class DidntRegisterUpgradeRequireItem extends CustomError {
    constructor() {
        let message = '승급에 필요한 아이템이 등록하지 않았다';
        let code = 80522;
        super(message, code);
    }
}

/** 80523 */
class NoLongerUpgrade extends CustomError {
    constructor() {
        let message = '더 이상 승급이 불가능(최대 등급 도달)';
        let code = 80523;
        super(message, code);
    }
}


9개 클래스를 모두 등록했으면 다음 부분을 찾아서 아래와 같이 수정한다.

  • 찾아야하는 내용

    "NickNameToLongOrShot":NickNameToLongOrShot
    
  • 변경할 내용

"NickNameToLongOrShot":NickNameToLongOrShot,

"NotInOwnItemList":NotInOwnItemList,
"MaterialDoesNotExistOwnItemList":MaterialDoesNotExistOwnItemList,
"NotEnoughItem":NotEnoughItem,

"CantReinfoceItem":CantReinfoceItem,
"DidntRegisterReinfoceRequireItem":DidntRegisterReinfoceRequireItem,
"NoLongerReinforce":NoLongerReinforce,

"CantUpgradeItem":CantUpgradeItem,
"DidntRegisterUpgradeRequireItem":DidntRegisterUpgradeRequireItem,
"NoLongerUpgrade":NoLongerUpgrade

테스트

아이템을 몇개 등록하고 강화와 승급도 진행해보자.

정의된 아이템 추가

DefineItem 테이블에 데이터를 입력한다.

ItemID ItemType Name Multiple MaxQNTY ReinforceItemID UpgradeItemID
101 10 key 1 500 NULL NULL
102 10 gem 1 900000 NULL NULL
103 10 gold 1 900000 NULL NULL
2001 1 sward 0 1 NULL NULL

ItemType 10은 통화를 지칭하기로 약속한다.

모든 길은 아이템으로 통해야 추후 관리가 쉽다. 그래서 통화도 아이템으로 등록한다.

다만 통화는 Currency에서 별도 관리는 것이라고 생각하면 된다.

materialCtrl.js 파일을 보면 공통 요청을 하지만 ItemType에 따라 적용하는 메서드가 달라지는 것을 볼 수 있다.

2001번 아이템은 현재는 강화와 승급이 불가능하다. 강화 승급에 관련한 데이터를 추가로 등록하자.

강화 관련 데이터 입력

아이템 강화와 관련된 테이블은 2개다.

DefineReinforceItemDefineReinforceRequireItem.

DefineReinforceItem을 먼저 입력한다.

ReinforceItemID
2001

그리고 DefineReinforceRequireItem에 아래 내용을 입력한다.

소모할 아이템이 통화로 사용할 gem, gold 뿐이라서 여기서는 모두 gold로 처리한다.

id Level RequireQNTY ReinforceItemID ItemID
NULL 1 10 2001 103
NULL 2 20 2001 103

승급 관련 데이터 입력

승급도 강화와 마찬가지로 2개 테이블에 입력해야한다.

먼저 DefineUpgradeItem을 입력한다.

UpgradeItemID
2001

그리고 DefineUpgradeRequireItem에 아래 내용을 입력한다.

id Tier RequireQNTY UpgradeItemID ItemID
NULL 1 100 2001 103
NULL 2 200 2001 103

정의된 아이템 수정

DBeaver에서 DefineItem 테이블을 선택하고 ItemID 2001 상품의 ReinforceItemIDUpgradeItemID를 각각 2001로 변경한다.

  1. DefineItem 테이블 선택.

  2. Data 탭에서 2001 아이템을 아래처럼 수정

    ReinforceItemID/UpgradeItemID변경

  3. 아래쪽에 Save를 클릭하여 변경사항을 반영한다.

    save

보유한 아이템 추가

1번 유저에게 2001번 아이템을 지급하자.

OwnItem테이블에 아래 내용을 추가한다.

OwnItemUID ItemID CurrentQNTY Level Tier UpdateTimeStamp GameUserID
NULL 2001 1 1 1 2017-01-01 1

postman으로 테스트

postman을 실행하고 토큰을 획득한 다음 아래처럼 요청을 해보자.

데이터를 로딩하는 4개 API는 테스트하지 않는다. 여기서는 강화와 승급만 다루겠다.

강화 요청 테스트

  • 패스 : POST
  • URL : localhost:3000/item/reinforce/1

위 URL에서 가장 뒷 부분은 OwnItemUID이다. 자신의 테이블을 확인해보고 OwnItemUID가 필자와 다르다면 자신의 값을 넣어야한다.

  • Headers : Authorization을 추가하고 토큰 내용을 value 부분에 넣는다.

  • body : 아래 내용을 넣는다.

{
    "materialItemUID":[],
    "materialCurrencyUID":[3]
}

materialCurrencyUID에는 OwnCurrency 등록된 소모할 OwnCurrencyUID를 넣는다. CurrencyID 103가 등록된 OwnCurrencyUID를 넣어줘야 정상 동작한다.

성공하면 다음 내용을 수신한다.

{
  "result": 0
}

DBeaver에서 OwnItem 테이블의 Data탭에서 새로고침버튼을 클릭하면 Level이 2로 반영된 것을 확인할 수 있다.

Dbeaver새로고침

승급 요청 테스트

  • 패스 : POST
  • URL : localhost:3000/item/upgrade/1

위 URL에서 가장 뒷 부분은 OwnItemUID이다. 자신의 테이블을 확인해보고 OwnItemUID가 필자와 다르다면 자신의 값을 넣어야한다.

  • Headers : Authorization을 추가하고 토큰 내용을 value 부분에 넣는다.

  • body : 아래 내용을 넣는다.

{
    "materialItemUID":[],
    "materialCurrencyUID":[3]
}

materialCurrencyUID에는 OwnCurrency 등록된 소모할 OwnCurrencyUID를 넣는다. CurrencyID 103가 등록된 OwnCurrencyUID를 넣어줘야 정상 동작한다.

성공하면 다음 내용을 수신한다.

{
  "result": 0
}

DBeaver에서 OwnItem 테이블의 Data탭에서 새로고침버튼을 클릭하면 Tier이 2로 반영된 것을 확인할 수 있다.

맺음말

아이템까지 어찌어찌 왔으니 자원에 영향을 주는 기능을 만들 때 필요한 기반이 되었다.

인앱 영수증 검증, 쿠폰, 메시지, 상점, 가챠 등

다음 강좌에서 지급 기능을 만들고 차차 진행해보도록 하자.

6강 바로가기


참고자료

완성된 소스코드는 아래 링크에서 다운로드받으면 된다.

Wendy 5강 완료 버전


Buy me a latteBuy me a latte