はじめに
この記事はC++ Advent Calendar 2024 10日目の記事です。
前日は@takoyaki-death さんの「C++を使ったlinuxのコンソールアプリ(CLR)を作る方法」 でした。
こんにちは、変愚蛮怒 開発チームの掘江です!
今年一年で一番大きかったリファクタリング (の1つ)の内容と、それによるQOLの向上結果を紹介していきます。
先にまとめ
この記事は長大です。
なので結論だけ先に書いておきます。
- 自作クラスの中にvector、list、queueなどのコンテナクラスをprivateフィールドに持たせよう
- 自作クラスの中でキャッシュを構成したり、集合論的メソッド (射影や抽出など)を自作しよう
まずはコード
このコード、基本全部私が書きました。
これの解説を行います。
(他担当者の作業も一部混じっています)
https://github.com/hengband/hengband/blob/3.0.1.21-Beta/src/system/baseitem-info.cpp
https://github.com/hengband/hengband/blob/3.0.1.21-Beta/src/system/baseitem-info.h
コードの前提知識
変愚蛮怒は「ローグライク」ゲームです。
「風来のシレン」を始めとした不思議のダンジョン系は少し遠い親戚です。
RPGなのでアイテムとか敵とかレベルとか魔法とかいう概念が存在します。
さて、本ゲームにおけるアイテムは「ID」と「〇〇の××番」という2種類のユニークなキーで管理しています。
これをBaseitemKey と呼んでいます。
IDは純粋にデータの追加順です。
後者をドラクエで例えれば、「『ひのきのぼう』は『武器の1番』」となります。
(実際は武器の中でも細かい分類があるのですが詳細は省略します)
変愚蛮怒の特徴
上記ソースコードに関係する範囲で特徴を2つ述べます。
アイテムの生成はランダムに決まる
ダンジョンに入ると、その床に何らかのアイテムが落ちていますが、それが何で、どこにあるかは毎回変わります。
普通のRPGと同じように、「冒険が進むほど強力なアイテムが手に入る」傾向はありますが、最深部でもゴミアイテム1 が落ちていることはザラです。
そうは言っても、例えば「武器と防具の出る確率は各10%、指輪の出る確率は5%」といった具合に何となく分布が決まっています。
(実際の確率はもっと複雑な計算で決まります)
アイテムは、拾った瞬間は何のアイテムか分からない
これはゲーム開始時にランダムに割り振られます。これを未鑑定名と呼びます。
例えば「致命傷治癒の薬」は、初期状態で「赤い薬」とか「青い薬」とかいう具合です。
同様に「警告の指輪」は、「真鍮の指輪」とか「飴細工の指輪」という具合です。
更に、変愚蛮怒ではアイテムに一定の規則を持たせています。
上の例を引き続き使うと、「薬系統のアイテムは薬専用の未鑑定名」、「指輪系統のアイテムは指輪専用の未鑑定名」を割り当てることになっています。
(未鑑定名を持たず、拾った瞬間に何のアイテムか分かるものもあります)
即ち、指輪が「赤い指輪」になったり、薬が「飴細工の薬」には絶対にならないことを意味します。
このドメイン仕様をどのように実現するでしょうか?
正解2 は、タイトルにもある通り「コンテナのラップ」です。
具体的にどういうことか見ていきましょう。
設計
では実際のコードをここから解説していきましょう。
冒頭に挙げたコードを改めてここへコピペします。
大方針
コンストラクタはprivate、それ以外の特殊コンストラクタ3 は全てdelete。
これを見てピンと来た方は素晴らしい!
そう、これはシングルトンパターン です。
ものの見事にvector をラップしているのが一目で分かるかと思います。
外部要因 (設定ファイルの構成)によってpush_back() は実装せず、代わりにresize() を使っています。
初期化処理が完了すると、要素の追加/削除は不可能になる仕組みです。
enum class ItemKindType : short;
enum class MonraceId : short;
class BaseitemDefinition;
class BaseitemKey;
class BaseitemList {
public:
BaseitemList(BaseitemList &&) = delete;
BaseitemList(const BaseitemList &) = delete;
BaseitemList &operator=(const BaseitemList &) = delete;
BaseitemList &operator=(BaseitemList &&) = delete;
~BaseitemList() = default;
static BaseitemList &get_instance();
BaseitemDefinition &get_baseitem(const short bi_id);
const BaseitemDefinition &get_baseitem(const short bi_id) const;
std::vector<BaseitemDefinition>::iterator begin();
std::vector<BaseitemDefinition>::const_iterator begin() const;
std::vector<BaseitemDefinition>::iterator end();
std::vector<BaseitemDefinition>::const_iterator end() const;
std::vector<BaseitemDefinition>::reverse_iterator rbegin();
std::vector<BaseitemDefinition>::const_reverse_iterator rbegin() const;
std::vector<BaseitemDefinition>::reverse_iterator rend();
std::vector<BaseitemDefinition>::const_reverse_iterator rend() const;
size_t size() const;
bool empty() const;
void resize(size_t new_size);
void shrink_to_fit();
short lookup_baseitem_id(const BaseitemKey &bi_key) const;
const BaseitemDefinition &lookup_baseitem(const BaseitemKey &bi_key) const;
void reset_all_visuals();
void reset_identification_flags();
void mark_common_items_as_aware();
void shuffle_flavors();
private:
BaseitemList() = default;
static BaseitemList instance;
std::vector<BaseitemDefinition> baseitems{};
short exe_lookup(const BaseitemKey &bi_key) const;
const std::map<BaseitemKey, short> &create_baseitem_keys_cache() const;
const std::map<ItemKindType, std::vector<int>> &create_baseitem_subtypes_cache() const;
BaseitemDefinition &lookup_baseitem(const BaseitemKey &bi_key);
void shuffle_flavors(ItemKindType tval);
};
ここで、BaseitemDefinition とは、上に挙げたような具体的なアイテム(の雛形4)を表すクラスです。
例えば、「『ロング・ソード』の雛形」から「普通のロング・ソード」・「呪われたロング・ソード」・「魔法がかかったロング・ソード」などといった実アイテムを別々のオブジェクトとして生成するイメージです。
雛形から実アイテムを生成するルーチンは複雑なので省略します。
そして、BaseitemKeyはイミュータブルな値オブジェクトとして定義しています。
「レアアイテムか否か」などといったbool系のメソッドが大集合しています。
#pragma once
#include "object/tval-types.h"
#include <optional>
enum class ItemKindType : short;
class BaseitemKey {
public:
constexpr BaseitemKey()
: type_value(ItemKindType::NONE)
, subtype_value(std::nullopt)
{
}
constexpr BaseitemKey(const ItemKindType type_value, const std::optional<int> &subtype_value = std::nullopt)
: type_value(type_value)
, subtype_value(subtype_value)
{
}
bool operator==(const BaseitemKey &other) const;
bool operator!=(const BaseitemKey &other) const
{
return !(*this == other);
}
bool operator<(const BaseitemKey &other) const;
bool operator>(const BaseitemKey &other) const
{
return other < *this;
}
bool operator<=(const BaseitemKey &other) const
{
return !(*this > other);
}
bool operator>=(const BaseitemKey &other) const
{
return !(*this < other);
}
ItemKindType tval() const;
std::optional<int> sval() const;
bool is_valid() const;
bool is(ItemKindType tval) const;
ItemKindType get_arrow_kind() const;
bool is_spell_book() const;
bool is_high_level_book() const;
bool is_melee_weapon() const;
bool is_ammo() const;
bool has_unidentified_name() const;
bool can_recharge() const;
bool is_wand_rod() const;
bool is_wand_staff() const;
bool is_protector() const;
bool can_be_aura_protector() const;
bool is_wearable() const;
bool is_weapon() const;
bool is_equipement() const;
bool is_melee_ammo() const;
bool is_orthodox_melee_weapon() const;
bool is_broken_weapon() const;
bool is_throwable() const;
bool is_wieldable_in_etheir_hand() const;
bool is_rare() const;
short get_bow_energy() const;
int get_arrow_magnification() const;
bool is_aiming_rod() const;
bool is_lite_requiring_fuel() const;
bool is_junk() const;
bool is_armour() const;
bool is_cross_bow() const;
bool should_refuse_enchant() const;
bool is_convertible() const;
bool is_fuel() const;
bool is_lance() const;
bool is_readable() const;
bool is_corpse() const;
bool is_monster() const;
bool are_both_statue(const BaseitemKey &other) const;
private:
ItemKindType type_value;
std::optional<int> subtype_value;
bool is_mushrooms() const;
};
抽出系メソッド
前置きが長くなりましたが、これがこの記事のキモです。
「どんな条件で抽出するか?」は、DB系の皆様なら頭や胃が痛くなるご存知かと思いますが、ものすっごくドメイン仕様 (≒個々のプロジェクト要件)に依存します。
なので、本記事では抽出条件はユースケースを挙げるに留めます。
抽出系メソッドは、大きく分けて2つあります。
これがラッピングによりどのような効果が上がったのかの具体例を2つ挙げます。
- 条件に合うデータを1つだけ取り出すもの (いわゆるルックアップ)
- 条件に合う部分集合を取り出すもの (C#で言えばLINQのWhere)
ルックアップ
最も具体的な例は、変愚蛮怒ドメインの場合「BaseitemKeyからIDを引く」です。
この2つは1対1対応があるのですが、IDが必ず昇順であるのに対しBaseitemKeyに順序保証はありません。
まさにルックアップの出番、という訳です。
変愚蛮怒ドメインでは、キャッシュを構築して素早く検索できるようにしてあります。
(武器、薬、指輪など大項目別のmap)
C++には「ローカルstatic変数」が定義できるので、メソッドの中にキャッシュ本体が格納されています。
C#ではキャッシュ変数は以下のようにフィールド化すると良いでしょう。
private readonly Dictionary<ItemKindType, List<int>> cache = new();
具体的には以下の処理を行います。
- 空 (=初アクセス時)ならばキャッシュを構築しそこからルックアップして返す
- 2回目以降のアクセス時ならば構築済のキャッシュからルックアップして返す
キャッシュ内容は外部の設定ファイルに依存するので、起動時初期化処理のかなり後半でやっと確定します。
そのため遅延評価を行います。
過去はキャッシュの機構がなく、ぐっちゃぐちゃで何をやっているか分からない5 有り様でしたが、このリファクタリングにより可読性と保守性の向上も実現できました。
部分集合の抽出
「全アイテムの中から指輪だけ抽出する」といったコード上の操作は、もはやゲームでは必須と言っても良いのではないでしょうか。
変愚蛮怒のソースコードは全合計で約26万行あり、抽出ロジック1つとってもコードの凝集度が低くてどこにどんな条件があるか不明瞭でした。
まだまだ改善の余地はありますが、「全体集合から部分集合を返せるクラスが存在する」というだけで多大なメリットがあります。
これはSTL単独では不可能なことです。当然vector/map に直接アクセスできるなら「そこ」で何でもできちゃいますからね。
カプセル化はオブジェクト指向設計の超重要ポイント です。
成果
ここまでやった結果、何が得られたのか?
それを解説していきます。
可読性&保守性&凝集度向上
C++のSTLが読める方なら、ドメイン仕様の細かいところは分からなくても、ここまでの説明で最初に示したコードは「何となくvectorやmapをforループで回して抽出したり合計を計算したりしてるなー」というのが分かるかと思います。
この「何となく」がまさに重要です。
「C/C++の読み書きができて、ゲーム内のドメイン知識も豊富なのに、肝心のコードはステップ実行してすら何をやっているか理解できない」なんてロス変愚蛮怒では日常茶飯事です。
実際、このコードがコミットされてから僅か1週間で新しい仕様が提案され、承認され、developラインにマージされ、テストされ、そして本記事投稿時点ではリリース待ちの状態です。
これは、今までの開発ペースからすると尋常ならざるとんでもない速度6です。
未鑑定名シャッフル処理の一元化
薬は薬同士、巻物は巻物同士で未鑑定名をシャッフルする。
口で言うと簡単ですが、C言語でこれを実装しようとすると地獄の一言に尽きます。
C++で豊富なSTLを使えることにより、アイテム種別ごとの辞書を作ってrange-based for でpair を回しながらシャッフルすることに成功しました。
以下のような型を実際に用いています。
std::map<ItemKindType, std::vector<int> // ItemKindType は「武器」「薬」などのアイテム種別を表すenum class
std::map<BaseitemKey, short>
財宝の仕様改善
変愚蛮怒には「財宝」カテゴリのアイテムがあります。
具体的には銅貨・銀貨・金貨などです。
そして、この記事が執筆されるほんの1ヶ月前まで、以下のようなクソ仕様でした。
- 財宝のアイテム雛形IDは必ず480番から始まらなければならない
- 財宝アイテムの雛形IDは必ず連番で定義しなければならない
- ID数は必ず18個でなければならない
- 必ず価値の低い順番にIDが定義されていなければならない
何でそうなったのか? 実は筆者すら分かりません。
それは変愚蛮怒が現在は消滅してしまったZangband からのフォークであり、そのZangbandが30年前から20年前まで丹精込めて継ぎ足したからです。
もはや真相は闇に葬られました。
フォークから20年以上経過した今、開発メンバーにできることは、この邪悪なる仕様を必死こいて撃退することだけです。
以上のインプットがある状態で改めてコードを見ると設計意図が浮かび上がってくるかと思いますが、キャッシュ化・部分集合化によって、4つの制約条件を全て外すことに成功しました。
上には挙げませんでしたが、「財宝の中でも必ず金貨だけを落とすモンスターの特別な処理」が存在し、それを担当するグローバル変数がありましたが、無事撲滅できました。
最初にこの構想を頭の中に練り始めてから1年7 の月日が経っていました。長かった……
なお、変愚蛮怒の場合、事前設定された「アイテム雛形定義ファイル」を読み込んで全てメモリに展開します。
DBがある環境でも、例えばC#ならEntity Framework Core でほぼ同様の設計を作ることができるので、ぜひ試してみて下さい。
コードベース改善
先に書いた通り、ソースコードはおよそ26万行あります。
C言語時代も25万行ほどありましたが、古き悪しき習慣により無駄なコメントだらけだったので一時期19万行くらいまで減らしました。
その後、C++に切り替えた上で「人類には読めないコード」を紐解いてモダン化した結果、再び元の行数まで戻ってきました今の調子だと40万くらいまで伸びそう
この過程で、私個人の能力として信じがたいほど「昔の人間が考えなしに書いたクソコード8への耐性」が大幅に増加しました。
35年以上の歴史を持ち、つい3年前にC++でのコンパイルに成功したばかりのソースコードはどんな問題があるか?
それは、「ロジックが散らばりすぎていて何が何だか分からない」です。
毎回毎回Visual Studioの参照検索機能でそれっぽいところを探し回る毎日です。
本記事では紹介しませんが、これなんか目じゃないほどもっともっとぐちゃぐちゃに散らばっていて、人類の脳内メモリには格納できないグローバル変数もあります。
来年はその辺りを必死こいて解体していく予定です。
おわりに
長々とありがとうございました!
変愚蛮怒は開発コミュニティとユーザコミュニティが同じサーバにあるフレンドリーな場所なので、ぜひDiscordにも遊びに来て下さい!
開発メンバーも随時募集中です!
明日は@yohhoy さんの「<<」 です。 (公開されたらURLを付記します)
お楽しみに!
-
40年来の古い古いゲームなので、賑やかし以外の用途を持たないヘンなアイテムがいくつも現存しています。 ↩
-
何を隠そう、著者のHabu氏は変愚蛮怒の最古参開発メンバーで、今もなお技術面で重要な役割を、それもここには書ききれないほど沢山担って頂いています。 ↩
-
私が参画した当初、何もドキュメントがなく、C言語だったのでメソッドもなく、それどころか構造体にまとめるべきデータを個別にグローバル変数として扱っており、更に意味不明な省略単語 (「kind」!!)で構造体名が定義されていたので「kindとはアイテムの雛形という意味か!」と理解するまで1年以上かかりました。今はドキュメント化も少しずつ進み、単語も人類が理解できるようになってきています。 ↩
-
リファクタリングしたのは他ならぬ私自身のはずですが、昔の設計は覚えてもいないくらい複雑怪奇で人類には読めない代物でした
作業中だけ一時的に人外化しました。つい4年前までC言語だったことが原因です。 ↩ -
一番停滞していた時期では、「バグ修正が年に1コミットあるかないか」でした。今はGitHubのプルリクにして最高記録30回/日を達成しています。1PR5コミットとすると1日150コミットです! ↩
-
他に優先度の高いタスクが(比喩ではなく本当に)数百個も存在していたので、全然手がつけられませんでした…… ↩
-
LinuxカーネルのソースコードがFワードまみれなのは周知の事実(?)ですが、今ならリーナスの気持ち も分かります。 ↩