ポケモンから学ぶドメイン駆動設計〜相性〜
はじめに
オープンワールドに惹かれて、30 歳にしてポケモンを始めるようになりました。
ほとんどの人が知っている人気ゲームなので、ゲームの進め方はざっと知ってましたが、
相性 いわゆる属性の組み合わせに衝撃を受けました。
妻は子供の頃からプレイしていたので、それなりに身についているようですが、30 歳からスタートだと とても覚えられる数ではないですね。。。
しかし、どうやって実装しているかとか、自分だったらどう実装するかとか、職業病が発動したので、勉強がてら記事にまとめていきます。
本記事(シリーズ)では、以下のリポジトリを使用します。
言語は、Java17 / Gradle / Spring Boot
を使用しますが、使える構文のバリエーションの差があるものの、どの言語であっても考え方は変わらないと思います。私はオブジェクト指向が好きなので、それが使える言語の方がイメージしやすいかと思います。
今回のゴール
自身の属性に対して、相手の属性との相性・効果を返すメソッドを作成します。
ドメインを考える
例えば、じゃんけんの場合、グー、チョキ、パー の 3 種類の属性があり、それぞれに勝ち/負け/あいこ という効果が存在します。
↓ 自分/相手 → | グー | チョキ | パー |
---|---|---|---|
グー | あいこ | 勝ち | 負け |
チョキ | 負け | あいこ | 勝ち |
パー | 勝ち | 負け | あいこ |
ポケモンの場合は、ノーマル、ほのう、みず、くさ、、、、といった 18 種類の属性と
こうかばつぐん、こうかはいまひとつ、こうかなし、普通といった 4 種類の効果が存在します。
markdown で書くのは大変なので、公式にこれらの対応表があったので、使用させていただく。
これらの属性や効果というのが最小のビジネスロジックになるので、これらを閉じ込めた ドメインオブジェクト を定義します。
public enum Attribute {
NORMAL, // ノーマル
FIRE, // ほのお
WATER, // みず
GRASS, // くさ
ELECTRIC, // でんき
ICE, // こおり
FIGHTING, // かくとう
POISON, // どく
GROUND, // じめん
FLYING, // ひこう
PSYCHIC, // エスパー
BUG, // むし
ROCK, // いわ
GHOST, // ゴースト
DRAGON, // ドラゴン
DARK, // あく
STEEL, // はがね
FAIRY, // フェアリー
;
}
public enum Effectiveness {
GOOD, // 効果はばつぐん
NORMAL, // 効果は普通
NOT_GOOD, // 効果はいまひとつ
NONE, // 効果がない
;
}
相性を実装する
いろんな方法がありますが、属性と効果の変更頻度、そのとき仕様変更しやすいかを観点に考えます。
私が知っている限り、効果は変更がなかったかのように思えます。一方で、属性は増えていく傾向にあるので、その変更容易性は考えておくと良いでしょう。
ということで、次のような I/F を定義しておきます。これは、自分の属性が効果を受ける相手の属性を配列で返すメソッドを持っています。
I/F に従い該当する属性の配列を羅列すればよいですが、残り一つは全体の 余事象 になるので、 default method
にしておくと良いでしょう。今回は、相性表の中でも大部分を占めている「効果は通常」を余事象としました。こうすることで、実装を最小限にできそうですが、明示的に配列を定義する必要があるビジネスであれば、それに従うのがいいかと思います。
interface AttributeEffective {
List<Attribute> getGoodList();
default List<Attribute> getNormalList() {
// 他の余事象とする。
return Arrays.stream(Attribute.values())
.filter(a -> !getGoodList().contains(a))
.filter(a -> !getNotGoodList().contains(a))
.filter(a -> !getNoneList().contains(a))
.toList();
}
List<Attribute> getNotGoodList();
List<Attribute> getNoneList();
}
本来、I/F は実体がないことが多いですが、 default method
を持っているので、テストも忘れずに実装します。
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
class AttributeEffectiveTest {
@Test
void getNormalAgainstList() {
var actual = new TestAttributeEffective().getNormalList();
assertThat(actual, is(List.of(
Attribute.NORMAL,
Attribute.ELECTRIC,
Attribute.ICE,
Attribute.FIGHTING,
Attribute.POISON,
Attribute.GROUND,
Attribute.FLYING,
Attribute.PSYCHIC,
Attribute.BUG,
Attribute.ROCK,
Attribute.GHOST,
Attribute.DRAGON,
Attribute.DARK,
Attribute.STEEL,
Attribute.FAIRY))
);
}
private static class TestAttributeEffective implements AttributeEffective {
@Override
public List<Attribute> getGoodList() {
return List.of(Attribute.FIRE);
}
@Override
public List<Attribute> getNotGoodList() {
return List.of(Attribute.WATER);
}
@Override
public List<Attribute> getNoneList() {
return List.of(Attribute.GRASS);
}
}
}
属性の数だけあるので実装は大変ですが、例えば、ほのう、みずの場合は、以下のようになります。
import java.util.List;
class FireAttributeEffective implements AttributeEffective {
@Override
public List<Attribute> getGoodList() {
return List.of(
Attribute.GRASS,
Attribute.ICE,
Attribute.BUG,
Attribute.STEEL
);
}
@Override
public List<Attribute> getNotGoodList() {
return List.of(
Attribute.FIRE,
Attribute.WATER,
Attribute.ROCK,
Attribute.DRAGON
);
}
@Override
public List<Attribute> getNoneList() {
return List.of();
}
}
import java.util.List;
class WaterAttributeEffective implements AttributeEffective {
@Override
public List<Attribute> getGoodList() {
return List.of(
Attribute.FIRE,
Attribute.GROUND,
Attribute.ROCK
);
}
@Override
public List<Attribute> getNotGoodList() {
return List.of(
Attribute.WATER,
Attribute.GRASS,
Attribute.DRAGON
);
}
@Override
public List<Attribute> getNoneList() {
return List.of();
}
}
他のクラスも同じ要領で作れます。このクラスのメリットは、相性の属性を配列で持たせていることで、一眼でわかるようになっているところだと思います。もう一つは、 属性が増えたときに、このクラスを実装するだけ で簡単に相性を定義できます。ただし、これは、効果よりも属性の方が変更頻度が高いのが故の恩恵です。逆の場合は、I/F の作り方を変えるといいでしょう。
テストですが、配列を持つだけですし、 AttributeEffectiveTest
でもすべてのAttribute
を使用していることが担保されているので、省略します。
これらの実装クラスを Attribute
のメンバに持たせることで、本命のメソッドを実装することができます。
import lombok.AllArgsConstructor;
@AllArgsConstructor
public enum Attribute {
NORMAL(new NormalAttributeEffective()), // ノーマル
FIRE(new FireAttributeEffective()), // ほのお
WATER(new WaterAttributeEffective()), // みず
GRASS(new GrassAttributeEffective()), // くさ
ELECTRIC(new ElectricAttributeEffective()), // でんき
ICE(new IceAttributeEffective()), // こおり
FIGHTING(new FightingAttributeEffective()), // かくとう
POISON(new PoisonAttributeEffective()), // どく
GROUND(new GroundAttributeEffective()), // じめん
FLYING(new FlyingAttributeEffective()), // ひこう
PSYCHIC(new PsychicAttributeEffective()), // エスパー
BUG(new BugAttributeEffective()), // むし
ROCK(new RockAttributeEffective()), // いわ
GHOST(new GhostAttributeEffective()), // ゴースト
DRAGON(new DragonAttributeEffective()), // ドラゴン
DARK(new DarkAttributeEffective()), // あく
STEEL(new SteelAttributeEffective()), // はがね
FAIRY(new FairyAttributeEffective()), // フェアリー
;
private AttributeEffective attributeEffective;
/**
* 相性を取得する
*
* @param opponent 相手の属性
* @return 相性
*/
public Effectiveness getEffectiveness(Attribute opponent) {
if (attributeEffective.getGoodList().contains(opponent)) {
return Effectiveness.GOOD;
} else if (attributeEffective.getNormalList().contains(opponent)) {
return Effectiveness.NORMAL;
} else if (attributeEffective.getNotGoodList().contains(opponent)) {
return Effectiveness.NOT_GOOD;
}
return Effectiveness.NONE;
}
}
バグの検知と修正範囲の縮小化
これでもよいのですが、もう少し込み入った実装をしたいと思います。
if や switch, when の怖いところは、コンパイルレベルでのバグ検知ができないところです。仮に、新しい効果が導入されたとすると、
-
AttributeEffective
にメソッドを追加 - それぞれの属性クラスを実装
-
getEffectiveness
メソッドに if を追加
という流れになります。効果が増えるとなると、一大イベントなのでミスは起こりそうもないですが、コンパイルレベルで検知できることに越したことはないかと考えます。
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.List;
import java.util.function.Function;
@AllArgsConstructor
public enum Effectiveness {
GOOD(AttributeEffective::getGoodList), // 効果はばつぐん
NORMAL(AttributeEffective::getNormalList), // 効果は普通
NOT_GOOD(AttributeEffective::getNotGoodList), // 効果はいまひとつ
NONE(AttributeEffective::getNoneList), // 効果がない
;
@Getter
private Function<AttributeEffective, List<Attribute>> getAttributeFunction;
}
Effectiveness
に、 AttributeEffective
の対応するメソッドを返すような関数を持たせるようにしました。これにより、 Attribute
が大きく変わります。
/**
* 相性を取得する
*
* @param opponent 相手の属性
* @return 相性
*/
public Effectiveness getEffectiveness(Attribute opponent) {
return Arrays.stream(Effectiveness.values())
.filter(e -> e.getGetAttributeFunction()
.apply(attributeEffective).contains(opponent))
.findFirst()
.orElseThrow();
}
属性が増えたとしても、 このメソッド自体の仕様変更をせずに済みますし、 漏れがあった場合にコンパイルレベルの検知ができます。
慣れないうちは、ラムダ式は読みずらいかもしれないですが、属性 18 種、効果 4 種を組み合わせた if, for を実装、レビューする方が大変です。というよりほぼ不可能でしょうし、保守や仕様変更で事故るように思います。
テストも忘れずにと、言いたいところですが、分量が分量なので、AI の力を借りました。
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.stream.Stream;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
class AttributeTest {
@ParameterizedTest(name = "case {index}: {0} is {1} to {2}")
@MethodSource("provider")
void getEffectiveness(Attribute attribute, Attribute opponent, Effectiveness expected) {
var actual = attribute.getEffectiveness(opponent);
assertThat(actual, is(expected));
}
private static Stream<Arguments> provider() {
return Stream.of(
Arguments.of(Attribute.NORMAL, Attribute.NORMAL, Effectiveness.NORMAL),
Arguments.of(Attribute.NORMAL, Attribute.FIRE, Effectiveness.NORMAL),
Arguments.of(Attribute.NORMAL, Attribute.WATER, Effectiveness.NORMAL),
Arguments.of(Attribute.NORMAL, Attribute.GRASS, Effectiveness.NORMAL),
Arguments.of(Attribute.NORMAL, Attribute.ELECTRIC, Effectiveness.NORMAL),
Arguments.of(Attribute.NORMAL, Attribute.ICE, Effectiveness.NORMAL),
... 省略 全部で 18*18=324 個 流石にかけません
Arguments.of(Attribute.FAIRY, Attribute.ROCK, Effectiveness.NORMAL),
Arguments.of(Attribute.FAIRY, Attribute.GHOST, Effectiveness.NORMAL),
Arguments.of(Attribute.FAIRY, Attribute.DRAGON, Effectiveness.GOOD),
Arguments.of(Attribute.FAIRY, Attribute.DARK, Effectiveness.GOOD),
Arguments.of(Attribute.FAIRY, Attribute.STEEL, Effectiveness.NOT_GOOD),
Arguments.of(Attribute.FAIRY, Attribute.FAIRY, Effectiveness.NORMAL)
);
}
}
おまけ アクセスレベルの話
IDE によっては、デフォルトで、アクセスレベルを public
で作成するものがありますし、研修等でとりあえず public
と習うところも少なくないと思います。実際のところ、パッケージやアクセスレベルは、乱用防止、不正アクセス防止の観点で、地味に重要な実装だと思っています。
今回、実装したのは以下のクラスですが、 public
にしているのは、 Attribute
、 Effectiveness
だけです。残りについては、このパッケージの外から呼び出されることを想定しておらず、この二つだけで十分だからです。
kotlin
であれば、 shield
でさらに限定的な使い方ができそうですが、まだサンプルがないので、またおいおい話題に出せればと思います。
|- (public) Attribute.java
|- (public) Effectiveness.java
|- (package private) AttributeEffective.java
|- (package private) BugAttributeEffective.java
|- (package private) DarkAttributeEffective.java
|- (package private) DragonAttributeEffective.java
|- (package private) ElectricAttributeEffective.java
|- (package private) FairyAttributeEffective.java
|- (package private) FightingAttributeEffective.java
|- (package private) FireAttributeEffective.java
|- (package private) FlyingAttributeEffective.java
|- (package private) GhostAttributeEffective.java
|- (package private) GrassAttributeEffective.java
|- (package private) GroundAttributeEffective.java
|- (package private) IceAttributeEffective.java
|- (package private) NormalAttributeEffective.java
|- (package private) PoisonAttributeEffective.java
|- (package private) PsychicAttributeEffective.java
|- (package private) RockAttributeEffective.java
|- (package private) SteelAttributeEffective.java
|- (package private) WaterAttributeEffective.java
おわりに
ポケモンの基本仕様である相性だけでも、ドメイン駆動設計、実装スキルの学習教材に活用できそうです。例えば、HP や PP、攻撃などなど関係パーツのドメインを定義し、組み合わせていくことで、巨大なゲームを作れそうです。(あくまでビジネスロジックですが)
このようにして、勝手ながら実装ノウハウについて、今後もまとめていきたいと思います。あくまで個人が題材にしたものなので、実際と異なる点は多々あると思いますが、ご容赦ください。
Discussion