どうもです、タドスケです。
ゲーム開発で使えそうな部品を提供するこのシリーズ。
今回はRPGの戦闘ステータス計算機能(攻撃力のみ)を作ってみました。
この記事内で公開しているコードは全て自作なので、個人/商用問わずご自由に流用いただいて構いません。 ただしコードを利用して損害が出た場合でも、その責任は負いかねます。
更新履歴
- 2023/3/8
– 武器(Weapon) クラスを 装備品(Equipment) クラスとして汎用化した
– 攻撃力(atk)を Parameters クラスとして汎用化した - 2023/3/2
– クラス単位でファイルを分割
– Weapon クラスの生成を WeapoonFactory クラス経由で行うようにした
– 基礎能力の辞書データを外部から渡せるようにした - 2023/2/28
– 初回バージョンを公開
実行例
今回のプログラムを実行すると、以下のような出力が得られます。
[BattleStatus] 力=10 → 総攻撃力=10 力=10, 武器=銅の剣(Lv.1) → 総攻撃力=15 力=10, 武器=銅の剣(Lv.3) → 総攻撃力=17 力=10, 状態=ATK_UP → 総攻撃力=12 力=10, 状態=ATK_DOWN → 総攻撃力=7 力=10, スキル=ATK_UP(Lv.1) → 総攻撃力=12 力=10, スキル=ATK_UP(Lv.4) → 総攻撃力=18 力=10, 武器=鋼の剣(Lv.10), スキル=ATK_UP(Lv.5), 状態=ATK_UP → 総攻撃力=92
実装方法
実行例のような動作をするためにどのようなプログラムを書けばよいでしょうか。
モジュール(.py ファイル)単位で解説します。
parameters モジュール
class ParameterValue:
"""パラメーターの値.
:params value: 初期値
"""
MIN = 0
MAX = 999
def __init__(self, value: int) -> None:
if not self.MIN <= value <= self.MAX:
raise ValueError
self._value = value
@property
def value(self) -> int:
return self._value
def __eq__(self, other) -> bool:
if isinstance(other, ParameterValue):
return self._value == other.value
else:
return self._value == other
def __add__(self, other) -> ParameterValue:
if isinstance(other, ParameterValue):
return ParameterValue(self._value + other.value)
return ParameterValue(self._value + other)
class ParameterId(Enum):
"""パラメーター ID."""
ATK = auto()
DEF = auto()
MAT = auto()
MDF = auto()
DEX = auto()
SPD = auto()
LUK = auto()
class Parameters:
"""全パラメーター."""
def __init__(self) -> None:
self._values = {i: ParameterValue(0) for i in ParameterId}
def get(self, id_: ParameterId) -> ParameterValue:
"""パラメーターを取得します.
:params id_: パラメーター ID
:return: パラメーター
"""
return self._values[id_]
def set(self, id_: ParameterId, value: ParameterValue) -> None:
"""パラメーターを設定します.
:params id_: パラメーター ID
:params value: 設定する値
"""
self._values[id_] = value
def __add__(self, other) -> Parameters:
assert isinstance(other, Parameters)
for i in ParameterId:
self._values[i] += other.get(i)
return self
攻撃力、防御力などのパラメーターを表すモジュールです。
ParameterValue はパラメーターの値を表すクラスです。
int で表現してもよいのですが、最小値・最大値などの制約条件や関連する操作をクラス内にまとめるため、クラスで作っています。
このような実装を「値オブジェクト」と呼びます。
Parameters はパラメーターの集合を表すクラスです。
これが無い場合、キャラクターが ParameterValue のリストを持つような実装になり、攻撃力・防御力などの各パラメーターごとに似たような処理が散らばってしまうおそれがあります。
character モジュール
class Character:
"""キャラクター.
:param params: 初期パラメータ
"""
def __init__(self, params: Parameters = None) -> None:
if params is not None:
self._params = params
else:
self._params = Parameters()
self._equipments = AllEquipments()
self._condition = Condition()
self._skills = SkillDict()
@property
def params(self) -> Parameters:
"""全パラメーター."""
return self._params
@property
def condition(self) -> Condition:
"""状態異常."""
return self._condition
@property
def equipments(self) -> AllEquipments:
"""全部位の装備."""
return self._equipments
@property
def skills(self) -> SkillDict:
"""習得しているスキル."""
return self._skills
def calc_atk(self) -> int:
"""装備・スキルなどを加味した攻撃力を計算します.
:return: 攻撃力
"""
atk = self.params.get(ParameterId.ATK).value
atk = self._calc_atk_equip(atk)
atk = self.condition.apply_atk(atk)
atk = self.skills.apply_atk(atk)
return int(atk)
def _calc_atk_equip(self, atk: int) -> int:
"""装備品の攻撃力を適用します.
:params atk: 適用前の攻撃力
:return: 適用後の攻撃力
"""
params = self._equipments.calc_params()
return atk + params.get(ParameterId.ATK).value
キャラクターのモジュールです。
キャラクター自身の攻撃力のほか、武器、スキル、状態異常などを持ち、calc_atk 関数でそれらを加味した攻撃力を計算します。
このように様々な要素を集めるクラスは大きくなりがちなので、各要素をクラス化し、キャラクタークラス自体が機能を持たないようにしています。
equipment モジュール
class ItemName:
"""アイテム名."""
MAX_LENGTH = 12
def __init__(self, name: str) -> None:
if self.MAX_LENGTH < len(name):
raise ValueError
self._name = name
@property
def value(self) -> str:
return self._name
def __str__(self) -> str:
return self._name
def __eq__(self, other) -> bool:
if isinstance(other, ItemName):
return self._name == other.value
return self._name == other
@dataclass
class BaseData:
"""基本データ."""
name: ItemName = ItemName('') # 名前
part_id: int = 0 # 装備部位
params: Parameters = Parameters() # パラメーター
class Equipment:
"""装備品.
:params base_data: 基本データ
"""
def __init__(self, base_data: BaseData) -> None:
self._base_data = base_data
self._level = 1
@property
def name(self) -> ItemName:
"""名前."""
return self._base_data.name
@property
def part_id(self) -> int:
"""装備部位 ID."""
return self._base_data.part_id
@property
def level(self) -> int:
"""レベル."""
return self._level
def set_level(self, level: int) -> None:
"""レベルを設定します.
:params level: レベル
"""
assert 1 <= level
self._level = level
def calc_params(self) -> Parameters:
"""レベルを加味したパラメーターを計算します.
:return: パラメーター
"""
params = Parameters()
for pid in ParameterId:
base_param = self._base_data.params.get(pid)
if base_param is None:
continue
level_value = self._calc_level_param(pid)
params.set(pid, ParameterValue(base_param.value + level_value))
return params
def _calc_level_param(self, id_: ParameterId) -> int:
"""武器レベルによる補正値を計算します.
:return: 補正値
"""
if self._level == 1:
return 0
param = self._base_data.params.get(id_)
if param is None:
return 0
base = param.value
ratio = float(self._level - 1.0) / 5.0
return int(base * ratio)
class AllEquipments:
"""全部位の装備."""
def __init__(self):
self._equipments: dict[int, tp.Optional[Equipment]] = {}
def set(self, eq: Equipment) -> tp.Optional[Equipment]:
"""装備品を設定します.
同じ部位に既に装備があった場合は入れ替わります.
:param eq: 装備品
:return: 入れ替わった装備。空の場合は None
"""
before = self._equipments.get(eq.part_id)
self._equipments[eq.part_id] = eq
return before
def get(self, part_id: int) -> tp.Optional[Equipment]:
"""指定部位の装備を取得します.
:param part_id: 部位 ID
:return: 装備品。装備していない場合は None
"""
return self._equipments.get(part_id)
def pop(self, part_id: int) -> tp.Optional[Equipment]:
"""指定部位の装備を外します.
:param part_id: 部位 ID
:return: 装備。装備していなかった場合は None
"""
before = self._equipments.pop(part_id, None)
return before
def calc_params(self) -> Parameters:
"""全部位の装備パラメーターの合計値を得ます."""
params = Parameters()
for eq in self._equipments.values():
params += eq.calc_params()
return params
装備品のモジュールです。
Equipment クラスが装備品で、基礎パラメーター・武器レベル・装備部位を持っています。
武器レベルが上がるごとに基礎パラメータの20%分のパラメーターが上がります。
基礎パラメーターは、モジュール外で BaseData オブジェクトを用意して生成時に渡すようにしています。
実際に利用する際には、データベースや Excel ファイルなどから装備品の dict を作り、必要に応じて生成するかたちになるでしょう。
AllEquipments クラスは部位ごとの装備を管理しています。
同じ部位に別の武器を装備しようとしたら入れ替わったり、素手の時に武器を取得しようとしたら None を返したりします。
calc_params 関数で全ての部位の装備のパラメーターを計算し、Parameters オブジェクトでまとめて返します。
skill モジュール
class SkillId(Enum):
"""スキル ID."""
ATK_UP = auto() # 攻撃力アップ
DEF_UP = auto() # 防御力アップ
class SkillDict:
"""スキル辞書."""
def __init__(self) -> None:
self._dict: dict[SkillId, int] = {}
def add(self, skill_id: SkillId, level: int = 1) -> None:
"""スキルを追加します.
:param skill_id: スキル ID
:param level: スキルレベル
"""
self._dict[skill_id] = level
def has(self, skill_id: SkillId) -> bool:
"""指定のスキルを持っているか?
:param skill_id: スキル ID
:return: 持っていたら True
"""
return skill_id in self._dict
def get_level(self, skill_id: SkillId) -> int:
"""スキルレベルを得ます.
:param skill_id: スキル ID
:return: スキルレベル
"""
if not self.has(skill_id):
return 0
return self._dict[skill_id]
def apply_atk(self, atk: float) -> float:
"""スキルを攻撃力に適用します.
:param atk: 適用前の攻撃力
:return: 適用後の攻撃力
"""
if self.has(SkillId.ATK_UP):
atk += self.get_level(SkillId.ATK_UP) * 2
return atk
キャラクターが持っているスキルを表すモジュールです。
キャラクターに add_skill / get_skill_level のような関数が増えるのが嫌だったので、スキルの集まり自体をクラス化しました。
このような実装方法をファーストクラスコレクションともいいます。
スキルの種別とスキルレベルを持っており、apply_atk 関数で、攻撃力に影響するスキルを持っていたら適用させています。
攻撃力に関係するスキルを持っていない場合は、apply_atk 関数を呼んでも何も起きません。
SkillDict クラスを利用する側に計算処理を書くこともできますが、「スキルレベルの2倍を攻撃力に加算する」というような仕様が外部に漏れてしまうため、スキル仕様を後から変えるのが面倒になります。
condition モジュール
class ConditionId(Flag):
"""状態異常 ID."""
ATK_UP = auto() # 攻撃力アップ
ATK_DOWN = auto() # 攻撃力ダウン
class Condition:
"""状態異常.
:param id_: 状態異常 ID
"""
def __init__(self, id_: ConditionId = None) -> None:
self._id = id_
def has(self, id_: ConditionId) -> bool:
"""状態異常を持っているか?
:param id_: 状態異常 ID
:return: 状態異常を持っていたら True
"""
if self._id is None:
return False
return id_ in self._id
def add(self, id_: ConditionId) -> None:
"""状態異常を付与します.
:param id_: 状態異常 ID
"""
if self._id is None:
self._id = id_
else:
self._id |= id_
self._offset()
def remove(self, id_: ConditionId) -> None:
"""状態異常を解除します.
:param id_: 状態異常 ID
"""
if self._id is None:
return
self._id &= ~id_
self._offset()
def _offset(self) -> None:
"""同時に付与できない状態異常を相殺します."""
both = ConditionId.ATK_UP | ConditionId.ATK_DOWN
if both in self._id:
self._id = None
def apply_atk(self, atk: float) -> float:
"""状態異常を攻撃力に適用します.
:param atk: 適用前の攻撃力
:return: 適用後の攻撃力
"""
if self.has(ConditionId.ATK_UP):
atk *= 1.25
elif self.has(ConditionId.ATK_DOWN):
atk *= 0.75
return atk
状態異常を表すモジュールです。
武器やスキルと違ってレベルを持たないので、enum.Flag を利用して、一つの変数で複数の状態異常をまとめて管理しています。
攻撃アップと攻撃ダウンは同時には起きないため、状態異常が更新されたときに _offset 関数で相殺させるようにしています。
ドラクエで言えば、スカラがかかっている時にルカニをかけられると、ルカニ状態になるのではなくスカラが解除される、ということです。
スキル同様、apply_atk 関数で攻撃力に適用させています。
コード
コード全文は以下の場所にアップしてあります。
実装
テスト
拡張のアイデア
ダメージ計算と組み合わせる
calc_atk 関数が攻撃力の代わりに先日公開したダメージ計算機能にある AttackInfo を返すようにすれば、今回の機能で計算した攻撃力でダメージ計算を行うことができます。
さらにその新・ダメージ計算機能をターンバトルシステムと組み合わせれば、より本格的なバトルシステムが作れます。
装備品の追加パラメータ
パラメーター以外にも武器属性、種族特攻、状態異常付与などの機能を持たせると面白いかもしれません。
Condition、Skill の種類を増やす
現在は攻撃力まわりの処理しか実装していませんが、防御力の処理を追加したり、状態異常耐性などを追加することもできます。
以上、戦闘ステータス計算機能の説明でした!
コメント