0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ポケモンバトルをDDDで実装したい

Last updated at Posted at 2025-09-14

はじめに

ポケモンバトルをDDDで実装していきます。

モデリング

どこまで実施していくかなどは明確に定義していない。というよりもできないので、ざっくりまずは作っていきます。必要に応じて拡張していく形にしていきます。

image.png

marmaid.jsのコード
classDiagram
class Battle {
  +Turn : int <<ターン数>>
  +PlayerA : Party <<プレーヤーA>>
  +PlayerB : Party <<プレーヤーB>>
}
note for Battle "バトル全体を表す集約ルート"

class Party {
  +TrainerID : string <<トレーナーID>>
  +Active : Pokemon <<バトルに出ているポケモン>>
  +Bench : List~Pokemon~ <<控えポケモンのリスト>>
}
note for Party "トレーナーのポケモンパーティを表す"

class Pokemon {
  +Species : string <<種族名>>
  +Stats : Stats <<能力値>>
  +HP : HP <<体力>>
  +Status : Status <<状態異常>>
  +Moves : Move[4] <<技スロット>>
}
note for Pokemon "個々のポケモンを表す"

class Move {
  +Name : string <<技名>>
  +Type : string <<タイプ>>
  +Power : int <<威力>>
  +Accuracy : float <<命中率>>
}
note for Move "ポケモンが使う技を表す"

class HP {
  +Max : int <<最大HP>>
  +Cur : int <<現在HP>>
}
note for HP "ポケモンのHP(体力)を表す"

class Stats {
  +Atk : int <<攻撃>>
  +Def : int <<防御>>
  +Spe : int <<素早さ>>
}
note for Stats "ポケモンの能力値(攻撃/防御/素早さ など)"

Battle "1" --> "2" Party : has
Party "1" --> "1..6" Pokemon : owns
Pokemon "1" --> "1..4" Move : has
Pokemon "1" --> "1" HP
Pokemon "1" --> "1" Stats

フェーズ1でやりたいこと(MVP:1ターンの戦闘)

🎯 目的

  • ポケモンバトルの最小単位をDDDで動かす
  • まずは「1ターンの攻防」が正しく流れるかを確認する

🧩 スコープ(やること)

  1. バトル全体(Battle集約)

    • 2人のプレーヤー(Party)を持つ
    • TakeTurn で1ターン進行
  2. ポケモン(Pokemonエンティティ)

    • HP・能力値・技を持つ
    • ダメージを受けたらHPを減らす
  3. 技(Move値オブジェクト)

    • 威力・命中率・優先度だけを保持
    • タイプや追加効果はまだ入れない
  4. DomainService(戦闘ルール)

    • OrderService : 先制技 → 素早さで行動順を決める
    • AccuracyService : 命中判定(この段階では100%命中固定)
    • DamageService : 超シンプルなダメージ式(相性・乱数なし)
  5. イベントログ

    • 「○○の○○!→××ダメージ」「××はひんしになった!」など
    • バトルの進行を後から追えるようにする

🚫 スコープ外(まだやらない)

  • 複数ターンのループ
  • 勝敗判定
  • タイプ相性・STAB・乱数
  • 状態異常・天候
  • 交代や瀕死時の入れ替え

✅ ゴール(Doneの定義)

  • コマンドで お互いの技を1つ選んで1ターン進行できる
  • 行動順が優先度→素早さで決まる
  • HPが減る、0なら「ひんし」のイベントが出る
  • 単体のテストで再現可能(決定的な動作)

全体概要/クラス図

ディレクトリ構成
.
├── cmd
│   └── battle_cli.go
├── docs
│   └── domain.puml
├── domain
│   ├── battle
│   │   └── battle.go
│   ├── move
│   │   └── move.go
│   ├── pokemon
│   │   ├── hp.go
│   │   ├── moveset.go
│   │   ├── pokemon.go
│   │   ├── species.go
│   │   └── stats.go
│   └── services
│       └── services.go
├── domain.md
└── go.mod

7 directories, 12 files

image.png

コード詳細

domain/move/move.go
package move

// Move は技を表す値オブジェクト
type Move struct {
	Name     string  // 技名
	Type     string  // 技タイプ(例: Fire, Water, Grass)
	Power    int     // 威力
	Accuracy float64 // 命中率(0.0〜1.0)
	Priority int     // 優先度(でんこうせっか=1、通常技=0)
}

domain/pokemon/hp.go
// domain/pokemon/hp.go
package pokemon

// HP はポケモンの体力を表す値オブジェクト
type HP struct {
	Max int
	Cur int
}

// Apply ダメージを適用して新しいHPを返す(0未満にならない)
func (h HP) Apply(dmg int) HP {
	if dmg < 0 {
		dmg = 0
	}
	cur := h.Cur - dmg
	if cur < 0 {
		cur = 0
	}
	return HP{Max: h.Max, Cur: cur}
}

// IsFainted 瀕死状態かどうか
func (h HP) IsFainted() bool {
	return h.Cur == 0
}
domain/pokemon/moveset.go
package pokemon

import (
	"errors"
	"pokemon_ddd_v2/domain/move"
)

const MaxMoves = 4

// MoveSet は最大4つの技を保持する値オブジェクト風の薄い入れ物
type MoveSet struct {
	list []move.Move
}

func NewMoveSet(ms ...move.Move) (MoveSet, error) {
	if len(ms) > MaxMoves {
		return MoveSet{}, errors.New("too many moves")
	}
	cp := make([]move.Move, len(ms))
	copy(cp, ms)
	return MoveSet{list: cp}, nil
}

func (s MoveSet) Len() int { return len(s.list) }

func (s MoveSet) At(i int) (move.Move, error) {
	if i < 0 || i >= len(s.list) {
		return move.Move{}, errors.New("move index out of range")
	}
	return s.list[i], nil
}

func (s MoveSet) All() []move.Move {
	cp := make([]move.Move, len(s.list))
	copy(cp, s.list)
	return cp
}

// Learn は空きスロットがあれば追加、満杯ならエラー
func (s *MoveSet) Learn(m move.Move) error {
	if len(s.list) >= MaxMoves {
		return errors.New("move slots are full")
	}
	s.list = append(s.list, m)
	return nil
}

// Replace は指定スロットを別の技に差し替える
func (s *MoveSet) Replace(i int, m move.Move) error {
	if i < 0 || i >= len(s.list) {
		return errors.New("move index out of range")
	}
	s.list[i] = m
	return nil
}
domain/pokemon/species.go
package pokemon

// Species はポケモンの種族(型)情報(MVPでは名前と単タイプのみ)
type Species struct {
	Name string
	Type string // 例: "Fire", "Water", "Grass"
}

domain/pokemon/stats.go
package pokemon

// Stats はポケモンの能力値(最小セット)
type Stats struct {
	Atk int // 攻撃
	Def int // 防御
	Spe int // 素早さ
}

domain/pokemon/stats.go
package pokemon

import (
	"errors"
	"pokemon_ddd_v2/domain/move"
)

// Pokemon はバトルで戦う個体(Entity)
type Pokemon struct {
	Species Species
	Stats   Stats
	HP      HP
	Moves   MoveSet
}

// NewPokemon は不変条件をチェックして生成
func NewPokemon(sp Species, st Stats, hp HP, moves ...move.Move) (*Pokemon, error) {
	if hp.Max <= 0 || hp.Cur <= 0 || hp.Cur > hp.Max {
		return nil, errors.New("invalid HP")
	}
	ms, err := NewMoveSet(moves...)
	if err != nil {
		return nil, err
	}
	return &Pokemon{
		Species: sp,
		Stats:   st,
		HP:      hp,
		Moves:   ms,
	}, nil
}

// ApplyDamage は被ダメ処理(下限0)— 自身の整合性を守る責務
func (p *Pokemon) ApplyDamage(dmg int) {
	p.HP = p.HP.Apply(dmg)
}

// IsFainted は瀕死判定
func (p *Pokemon) IsFainted() bool {
	return p.HP.IsFainted()
}

// MoveAt は i 番目の技を取り出す(Battle から参照される前提の読み取り専用)
func (p *Pokemon) MoveAt(i int) (move.Move, error) {
	return p.Moves.At(i)
}

// Learn/Replace は育成などの操作で利用(MVPでは未使用でもOK)
func (p *Pokemon) Learn(m move.Move) error          { return p.Moves.Learn(m) }
func (p *Pokemon) Replace(i int, m move.Move) error { return p.Moves.Replace(i, m) }
domain/services/services.go
package services

import (
	"pokemon_ddd_v2/domain/move"
	"pokemon_ddd_v2/domain/pokemon"
)

// Acting: 1回の行動に必要な情報
type Acting struct {
	Attacker *pokemon.Pokemon
	Defender *pokemon.Pokemon
	Move     move.Move
}

// ルール用インターフェイス
type OrderService interface{ Decide(a, b Acting) []Acting } // 行動順
type AccuracyService interface{ Hit(a Acting) bool }        // 命中判定
type DamageService interface{ Calc(a Acting) int }          // ダメージ計算

// まとめて渡すための構造体
type Services struct {
	Order    OrderService
	Accuracy AccuracyService
	Damage   DamageService
}

// ---------- 最小実装 ----------

// SimpleOrder: ①優先度 ②素早さ(同速は先に渡された側を先行に簡略)
type SimpleOrder struct{}

func (SimpleOrder) Decide(a, b Acting) []Acting {
	if a.Move.Priority != b.Move.Priority {
		if a.Move.Priority > b.Move.Priority {
			return []Acting{a, b}
		}
		return []Acting{b, a}
	}
	if a.Attacker.Stats.Spe >= b.Attacker.Stats.Spe {
		return []Acting{a, b}
	}
	return []Acting{b, a}
}

// AlwaysHit: 常に命中(フェーズ1のMVP用)
type AlwaysHit struct{}

func (AlwaysHit) Hit(_ Acting) bool { return true }

// SimpleDamage: 相性/STAB/乱数なしの最小式
type SimpleDamage struct{}

func (SimpleDamage) Calc(x Acting) int {
	atk := x.Attacker.Stats.Atk
	def := x.Defender.Stats.Def
	if def <= 0 {
		def = 1
	}
	base := (2*atk/5 + 2) * x.Move.Power / def / 5
	if base < 1 {
		base = 1
	}
	return base
}
	
domain/battle/battle.go
package battle

import (
	"fmt"

	"pokemon_ddd_v2/domain/pokemon"
	"pokemon_ddd_v2/domain/services"
)

type Party struct {
	TrainerID string
	Active    *pokemon.Pokemon
	Bench     []*pokemon.Pokemon
}

type Battle struct {
	Turn  int
	SideA Party
	SideB Party
	Log   []string
}

type MoveChoice struct{ Index int }
type TurnCommand struct {
	A MoveChoice
	B MoveChoice
}

func (b *Battle) TakeTurn(cmd TurnCommand, sv services.Services) []string {
	evs := []string{fmt.Sprintf("Turn %d start", b.Turn+1)}

	// 技を素直に取得(ここでエラーなら “no move” ログにする)
	am, aErr := b.SideA.Active.MoveAt(cmd.A.Index)
	bm, bErr := b.SideB.Active.MoveAt(cmd.B.Index)

	a := services.Acting{Attacker: b.SideA.Active, Defender: b.SideB.Active, Move: am}
	d := services.Acting{Attacker: b.SideB.Active, Defender: b.SideA.Active, Move: bm}

	for _, act := range sv.Order.Decide(a, d) {
		if act.Attacker.IsFainted() || act.Defender.IsFainted() {
			continue
		}

		// どちら側の行動かを判断して、取得エラーの詳細を出す
		if act.Attacker == b.SideA.Active && aErr != nil {
			evs = append(evs, fmt.Sprintf("%s tried to act but failed (no move): idx=%d err=%v",
				act.Attacker.Species.Name, cmd.A.Index, aErr))
			continue
		}
		if act.Attacker == b.SideB.Active && bErr != nil {
			evs = append(evs, fmt.Sprintf("%s tried to act but failed (no move): idx=%d err=%v",
				act.Attacker.Species.Name, cmd.B.Index, bErr))
			continue
		}

		if !sv.Accuracy.Hit(act) {
			evs = append(evs, fmt.Sprintf("%s の %s は外れた",
				act.Attacker.Species.Name, act.Move.Name))
			continue
		}

		dmg := sv.Damage.Calc(act)
		act.Defender.ApplyDamage(dmg)

		evs = append(evs, fmt.Sprintf("%s の %s → %d ダメージ (相手HP: %d/%d)",
			act.Attacker.Species.Name, act.Move.Name, dmg,
			act.Defender.HP.Cur, act.Defender.HP.Max))

		if act.Defender.IsFainted() {
			evs = append(evs, fmt.Sprintf("%s は ひんし になった", act.Defender.Species.Name))
		}
	}

	b.Turn++
	b.Log = append(b.Log, evs...)
	return evs
}

cmd/battle_cli.go
package main

import (
	"fmt"

	"pokemon_ddd_v2/domain/battle"
	"pokemon_ddd_v2/domain/move"
	"pokemon_ddd_v2/domain/pokemon"
	"pokemon_ddd_v2/domain/services"
)

func main() {
	// --- 技の定義 ---
	// 技①:たいあたり
	tackle := move.Move{
		Name:     "たいあたり",
		Type:     "ノーマル",
		Power:    40,  // 威力
		Accuracy: 1.0, // 命中率100%
		Priority: 0,   // 通常の優先度
	}
	// 技②:でんこうせっか(先制技)
	quickAttack := move.Move{
		Name:     "でんこうせっか",
		Type:     "ノーマル",
		Power:    40,
		Accuracy: 1.0,
		Priority: 1, // 優先度1(通常より先に行動)
	}

	// --- ポケモンを生成 ---
	// プレイヤーAのポケモン:ヒトカゲ
	a, _ := pokemon.NewPokemon(
		pokemon.Species{Name: "ヒトカゲ", Type: "ほのお"},
		pokemon.Stats{Atk: 52, Def: 43, Spe: 65}, // 攻撃・防御・素早さ
		pokemon.HP{Max: 100, Cur: 100},           // HP
		tackle, quickAttack,                      // 技
	)
	// プレイヤーBのポケモン:フシギダネ
	b, _ := pokemon.NewPokemon(
		pokemon.Species{Name: "フシギダネ", Type: "くさ"},
		pokemon.Stats{Atk: 49, Def: 49, Spe: 45},
		pokemon.HP{Max: 100, Cur: 100},
		tackle,
	)

	fmt.Printf("A moves len=%d: %+v\n", a.Moves.Len(), a.Moves.All())
	fmt.Printf("B moves len=%d: %+v\n", b.Moves.Len(), b.Moves.All())
	fmt.Println("A choose index=1, B choose index=0")

	// --- バトルを作成 ---
	bt := &battle.Battle{
		SideA: battle.Party{TrainerID: "プレイヤーA", Active: a},
		SideB: battle.Party{TrainerID: "プレイヤーB", Active: b},
	}

	// --- 戦闘ルールサービスを注入 ---
	sv := services.Services{
		Order:    services.SimpleOrder{},  // 行動順ルール(優先度→素早さ)
		Accuracy: services.AlwaysHit{},    // 命中100%
		Damage:   services.SimpleDamage{}, // 簡易ダメージ計算
	}

	// --- 1ターン実行 ---
	events := bt.TakeTurn(
		battle.TurnCommand{
			A: battle.MoveChoice{Index: 1}, // Aは「でんこうせっか」を選択
			B: battle.MoveChoice{Index: 0}, // Bは「たいあたり」を選択
		},
		sv,
	)

	// --- 結果を出力 ---
	for _, e := range events {
		fmt.Println(e)
	}
}
実行結果
~/develop/pokemon_ddd_v2  (feat/pattern1)$ go run cmd/battle_cli.go
A moves len=2: [{Name:たいあたり Type:ノーマル Power:40 Accuracy:1 Priority:0} {Name:でんこうせっか Type:ノーマル Power:40 Accuracy:1 Priority:1}]
B moves len=1: [{Name:たいあたり Type:ノーマル Power:40 Accuracy:1 Priority:0}]
A choose index=1, B choose index=0
Turn 1 start
ヒトカゲ の でんこうせっか → 3 ダメージ (相手HP: 97/100)
フシギダネ の たいあたり → 3 ダメージ (相手HP: 97/100)

どうでしたか??意味のある塊で固まっているのがいいですね。
DDDは最初実装するものが多いので初速は出ないんですが、徐々に速度が上がっていきます。
コードがそのまま仕様書になるので、だんだん良くなっていきます!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?