Studioz Advent Calendar day2の記事となります。
もう師走かぁ。掃除しなきゃなぁ・・・
というわけで今年で個人的に大掃除だった「ミッション機能」について。
負荷の少ないミッションをどう作るか
概要
- ソーシャルゲームを作る上で、どのタイトルでも企画化される「◯◯を3回クリアしよう!」的なミッション機能をどう設計するべきかを考える
ミッション機能のよくある要件
- ミッションは「全期間」「デイリー」「ウィークリー」などの期限設定がある
- 複数同時開催する
- TOPページで「クリア済があればNEWマーク(もしくはクリア済数)のバッジを出したい」
- 運用のったら後はマスタデータいれるだけで自動でシクヨロ
- (妄想の果てに)必要なミッションパターンは100個とか
課題
- NEWマークひとつ出すにしても「開催中のミッションのうちいずれか一つをクリアしているか?」が必要になる
- クリア済数の場合は当然全て舐めた上で、クリア数を返してやる必要がある
- ミッションはゲーム回遊促進の目的が強いことから、「所持カード数」「クエストのクリア回数」「ボスの撃破数」など幅広いデータの参照が必要になる事が多い
- 「全期間」「デイリー」「ウィークリー」クリア判定対象のデータが増えるほど設計次第だが負荷になる
ようは下手に作るとバッジ一つ出すにしても処理が死ぬほど高負荷になる
企画は面白さ・あるべき論を追求するべきで負荷なんて考慮しないのが常なので、
いかに将来的にも低負荷なミッションを作れるかはサーバーエンジニアには腕の見せどころである
(とはいえミッション機能は、設計コストもサーバー負荷も、非常に高い企画である事は理解してもらいたい)
設計パターン
パターンA:TOPページでクリア判定をするケース
- ◯)クリア判定がTOPページに集約される為、個別の機能側(クエストやバトルなど)ではクリア判定は不要
- ✕)クリア判定が仕込まれたTOPページは広範囲のデータを取得、判断する必要がある(負荷が高い)
パターンB:機能側でクリア判定をし、TOPページは新着情報の有無のみチェックするケース
- ◯)TOPページは新着情報の有無のみチェックする為、参照データが少なく負荷が低い
- ✕)各機能側にクリア判定処理を実装する必要がある
→コピペソースや、亜種の機能が多い場合影響範囲や保守範囲が広くなる
パターンC:非同期でなんとか(Batch処理でクリア判定するとか)
- ◯)ゲームサーバー自体への負荷は少ない
- ✕)反映がリアルタイムでない上、ユーザーの進捗とデッドロックする可能性も
よくある哀しみの事例
-
「ユーザーさんに色々やらせたい」と並列で開催するミッションが増えてたケース
-
なんでクエストクリア◯回が30個も並列開催してんのーーー!!etc..
-
機能追加で「◯◯機能の進捗もミッション追加しよう」と次から次へ新パターンが増えていくケース
-
似たような処理の乱立や、肥大化し続けるミッションクリア判定のメソッド・・・
-
なんでもマスタで設定できるようにしたいを素直に受け「◯◯を✕✕の時に△△をクリアするミッション」が実装される
-
条件設定の為マスタデータに増え続ける、そのミッションでしか使わない謎のカラム
-
しまいにはjsonでもたせるとかはじまり担当がいなくなって誰も使わないが負荷だけ残る
-
クリア対象となるデータ範囲が運用につれ拡張されていたケース(当然クリア判定数も応じて増える)
-
所持カードは当初100枚だったが、気づいたら1000枚越え・・・
-
対象ボスは気づいたら3体から100体に・・・
-
過去タイトルでも
頭の良さそうなコードで、**「これ当時は動いたんだろうけど今クエリ1000回流れてるケド・・・」**という実装になっているケースは多々。 -
対象データの将来の肥大化を意識しておかないと負荷になった頃には首が回らなくなる
-
肥大化するソース
public static function getProgressData( $userData, $missionData ) {
switch ( $missionData['checkType'] ) {
case BOSS_DEFEAT:
//ボスを撃破したかどうかの処理がN行
break;
case LV_UP:
//レベルがあがっているかの処理がN行
break;
case CLEAR_STAGE:
//ステージクリアしているかの処理がN行
break;
//延々と続くケース文。中身は仕様変更に合わせて細かい分岐条件が入れ子・・・
}
}
必要要件の整理
- ソースは肥大化させたくない
- クリア判定処理は各所に仕込みたくない
- TOPページは軽くしたい。でもクリア数は出したい
- ミッションパターンの拡張がしやすい。でも使われなくなっても負荷は残したくない
- 並列開催されても負荷は高くならないようにしたい
オワタ\(^o^)/
ソース肥大化問題への対策
Strategyパターンを利用する
- ミッションは性質上パターンが存在するので、パターンごとにアルゴリズムを変えるストラテジパターンが適切。
- ifelseやswitchが多様されるケースでは効果的な場合が多い
private static function _getMissionPattern( $userId, EnMissionData $missionData ) {
$model = null;
switch ( $missionData->getType() ) {
case MissionModel::TYPE_USER_LEVEL:
$pattern = new MissionPatternUserLv($userId);
break;
case MissionModel::TYPE_USER_TOTAL_TITLE_SET:
$pattern = new MissionPatternUserTitleSet($userId);
break;
case MissionModel::TYPE_USER_TOTAL_LEADER_MONSTER_SET:
$pattern = new MissionPatternUserLeaderMonsterSet($userId);
break;
}
return $pattern
}
クリア判定だとか前提条件チェックはそれぞれのMissionPatternクラスに書く。
このメソッドは「渡されたミッションのタイプから必要なインスタンスを返す」だけが責務。
各MissionPatternは汎化されたabstract missionPatternを継承し共通のクリア判定などはそちらを利用、
必要な拡張条件のみオーバーライドなり差分コーディングなり。
クラス設計
責務をそれぞれに分散させて継承でつなぐ。
一つのメソッドやクラスで複数の責務を担わせている匂いを感じたら立ち止まって設計を考えること。
抽象ミッションパターンクラスに実装した処理
- マスタデータのrowデータから対応する報酬データを返す
- 表示をスキップするかどうか
- ボーダー(必要クリア数など)を返す
- 進捗を返す
- クリアチェック処理
日次ミッションパターン実装
- データのsave先をdailyのテーブルに向ける
- userId + missionId + segmentId(YYYYMMDD)でunique idx
- 毎日レコードを増やす設計にするとデータ肥大化面倒(ログはDBでなくs3とか外に出そう)
- 更新はuserId + missionIdで、segmnetIdをupdateすることで当日を意識させる
- 使い手には前日のデータを意識させないように注意
週次ミッションパターン実装
- データのsave先をweeklyのテーブルに向ける
- userId + mission_id + segmentIdで同上
- segmentIdはYYYYMMDDでなく週次ミッションの起算日
- 翌週のデータを意識させないように注意
全期間ミッションパターン実装
- 特に意識することはなし
- ミッションが増えてきたときにどうするかはまだ答えがない・・・
クリア判定処理は各所に仕込みたくない
結論、トリガーは各所に仕込むようにした。
ただし数行でよいように設計する
①クリア情報をミッションマネージャに渡す
protected function execCommon() { //クエストの成否に限らず必ず呼ばれる処理
//-----------------------------------------
// ミッション更新
//-----------------------------------------
//判定に必要な情報をオブジェクト設定
$missionDto = new MissionClearDto();
$missionDto->questClearFlg = $this->reqClearFlg;
$missionDto->stageId = (int)$this->stageData['world_stage_id'];
//更新する
UserMissionDataManager::getInstance($this->userId)->execClearCheckByAction(MissionModel::CLEAR_CHECK_CATEGORY_QUEST, $missionDto);
UserMissionDataManager::getInstance($this->userId)->save();
//-----------------------------------------
//他のミッションのあれこれ
//-----------------------------------------
}
②開催中のものからタイプが一致するものだけ処理する
public function execClearCheckByAction($checkType, MissionClearDto $clearDataObj = null ) { //共通クリア判定処理
$res = 0;
$openUserMissionObjList = $this->_openMissionCollection->getOpenMissionList();
if ( !$openUserMissionObjList ) {
return $res;
}
foreach ( $openUserMissionObjList as $missionObj ) {
$tmpPattern = AMissionPatternModel::getInstance($this->_userId, $missionObj);
if ( !$tmpPattern ) {
$errorMsg = __METHOD__.__LINE__."[プログラムエラー]missionPatternが存在しない? missionId={$missionObj->getId()} mission_type={$missionObj->getType()}";
continue;
}
$tmpCheckType = $tmpPattern->getClearCheckType();
if ( $checkType != $tmpCheckType ) {
continue;
}
$this->execClearCheckById($missionObj, $clearDataObj);
}
return $res;
}
③指定したIDのミッションをクリア判定する(タイプごとに挙動だけ変わる)
public function execClearCheckById(EnMissionData $missionObj, MissionClearDto $clearDto = null) {
$status = AMissionPatternModel::CLEAR_CHECK_STATUS_NG;
if ( !$missionObj ) {
return $status;
}
//missionTypeごとにクリア判定を行う
$tmpPattern = AMissionPatternModel::getInstance($this->_userId, $missionObj);
if (!$tmpPattern) {
$errorMsg = __METHOD__.__LINE__."[プログラムエラー]missionPatternが存在しない? missionId={$missionObj->getId()} mission_type={$missionObj->getType()}";
VenusLogger::log($errorMsg);
return $status;
}
$status = $tmpPattern->execClearCheck($missionObj, $clearDto); //ここがmissionTypeごとに異なる振る舞いになる
if ( $status == AMissionPatternModel::CLEAR_CHECK_STATUS_CLEAR ) {
//外部からオンメモリをクリア済にする
$tmpPattern->setClearFlg($missionObj);
$tmpPattern->setResult($missionObj);
}
return $status;
}
①だけ使い回せば、いろんなところにクリア判定を仕込める
ex1)特定のガチャを実行した時
/**
* ミッション処理
* @return int
*/
protected function execMissionCheck() {
//判定に必要な情報をオブジェクト設定
$missionDto = new MissionClearDto();
$missionDto->gachaExecflg = ON_FLG;
$missionDto->gachaThemeId = $this->themeId
//更新する
UserMissionDataManager::getInstance($this->userId)->execClearCheckByAction(MissionModel::CLEAR_CHECK_CATEGORY_GACHA, $missionDto);
$res = UserMissionDataManager::getInstance($this->userId)->save();
return $res;
}
ex2)PVPで勝利した時
protected function execMissionCheck() {
if ( $this->roomData['room_type'] == AArenaRoomModel::ROOM_TYPE_TRAINING ) {
return false;
}
//判定に必要な情報をオブジェクト設定
$missionDto = new MissionClearDto();
$missionDto->arenaWinFlg = $this->reqResult;
//ミッションの更新
UserMissionDataManager::getInstance($this->userId)->execClearCheckByAction(MissionModel::CLEAR_CHECK_CATEGORY_ARENA, $missionDto);
UserMissionDataManager::getInstance($this->userId)->save();
return true;
}
TOPページは軽くしたい。でもクリア数は出したい
行動側に更新処理が仕込まれているので、基本的にはTOPページは「クリアしているか?」だけを意識すればよいので軽くなる。
クリア履歴があるものは判定をしないようにする
「クリア条件を満たしているか?」を毎回確認するから重くなるので、クリア履歴があるものは条件判定しないようにする
/**
* クリア判定処理
*
* この処理はlist取得時に呼ばれる為、ここにクエリでデータ取得を入れると負荷となる為、
* 原則的にはuser_mission_result_tの結果参照のみ行い、
* 各トリガーアクション側でどうしてもMAP遷移時などのクリアを実装する場合
* サブクラスのexecClearCheck()をオーバーライドして使うこと
*
* @param EnMissionData $missionObj
*
* @return mixed
*/
public function isClear( EnMissionData $missionObj ) {
$isClear = false;
if (empty($missionObj)) {
return $isClear;
}
$userMissionResultManager = $this->_getResultDataManager();
$userMissionResultObj = $userMissionResultManager->getResult($missionObj->getId());
if ( $userMissionResultObj ) {
$this->isClearList[$missionObj->getId()] = true;
$isClear = true;
}
return $isClear;
}
同種タイプで使い回せるものはstaticに持つ
/**
* クリア判定
* @param EnMissionData $missionObj
* @return bool
*/
public function isClear(EnMissionData $missionObj ) {
$isClear = parent::isClear($missionObj);
$targetItemId = $missionObj['targetId'];
//指定アイテムの所持情報を取得する
$userItemData = UserItemDataManager::getInstance($this->userId)->getUserItemData($targetItemId); //←ここ
if ( $missionObj['border'] <= $userItemData['num']) {
$isClear = true;
}
return $isClear;
}
所持アイテム情報はアイテムIDごとにクエリを投げるのではなく、一括で取得したItemDataManagerに聞きにいくだけにする。
ミッションパターンの拡張がしやすい。でも使われなくなっても負荷は残したくない
- missionPatternごとにstrategyパターン化
- クリア判定処理はmissionManagerを呼ぶだけ
これで使われないものはクリア判定は動かないし、patternが増えてもソースが増えるだけで済む。
並列開催されても負荷は高くならないようにしたい
- 同種タイプの進捗をstaticで持っていることである程度typeごとに負荷がかかりにくい
- アイテム数とか、ポイント数とかは共通化しやすい
- とはいえステージクリアみたいな広範囲のデータが必要なものは、一括でとるとメモリ負荷が大きいので悩ましい・・・
実際やってみてどう?
- 2019/4に担当サービスのミッションを上記のやり方でリニューアルしました
- 負荷はどうなった?
- 改善。400-500クエリ流れて1s近くかかっていたが30-50クエリほどに改善。劇的ビフォーアフター
- 作りやすさは?
- 設計意図を理解するまではちょっと敷居が高いかも
- でもエンジニア若手がミッション追加できてたみたいなので合格点かな
- 他によかったところ
- missionのマスタのカラムを掃除できてプランナーの負荷・バグリスクが減った
終わりに
ゲームサービスは高い確率でサービス途中でミッションを作ることになると思うので、早めに意識して設計しておくとよいと思います。
正解はないと思うのでもっとよい作りをこれから模索していければ。