【Python】斜方蹴射をリファクタリングしてみる

プログラミング

どうもです、タドスケです。

こちらを作る際、以前に作った斜方蹴射のゲームモデル部分を使い回したのですが、コードの書き方がだいぶ良くないなぁと思いました。

当時はPythonの実務経験がなく、コード規約なども知らずに「とにかく動くもの作る」ことに集中していました。

Pythonの実務経験(まだ3ヶ月ではありますが)を経て、今の自分の感覚で「綺麗なコード」を書いてみようと思います。

リファクタリングのやり方に関して、参考になる点があれば幸いです。




以前のコード

以前のコードを載せておきます。

"""
斜方蹴射 ゲームロジック
"""

import random
from enum import Enum


# ===========================
# ゲームモード
# ===========================
class Mode(Enum):
    INIT = 0  # 初期
    POW_X = 1  # Xパワー決定待ち
    POW_Y = 2  # Yパワー決定待ち
    FLYING = 3  # 飛行中
    WAIT_HIT = 4  # ヒット演出中
    WAIT_OUT = 5  # 外れ演出中
    TIME_UP = 6  # 時間切れ演出中
    RESULT = 7  # リザルト


# ===========================
# ゲーム本体
# ===========================
class Game:
    # クラス変数
    max_pow_x = 100  # パワー横最大値
    max_pow_y = 100  # パワー縦最大値
    max_time = 60  # 制限時間
    init_ball_x = 20  # ボール初期位置x
    init_ball_y = 280  # ボール初期位置y
    waittime_hit = 1.0  # ヒット演出時間
    waittime_end = 2.0  # タイムアップ演出時間
    ground_h = 20  # 地面の高さ
    min_target_x = 150
    max_target_x = 350
    min_target_y = 30
    max_target_y = 180
    scr_w = 400
    scr_h = 300

    # コンストラクタ
    def __init__(self):
        self.stage = Stage(Game.ground_h)
        self.reset()
        self.time = 0  # 残り時間
        self.mode = Mode.INIT  # モード番号
        self.decide = False  # 決定操作がされたか
        self.wait_timer = 0  # 演出タイマー
        self.score = 0  # スコア

    # 状態リセット
    def reset(self):
        self.ball = Ball()
        self.ball.reset(Game.init_ball_x, Game.init_ball_y)
        self.pow_x = 0
        self.pow_y = 0
        self.stage.reset_wind()
        self.target = Target()
        self.target.set_pos(random.randint(Game.min_target_x, Game.max_target_x),
                            random.randint(Game.min_target_y, Game.max_target_y))

    # 更新
    def update(self, delta):
        # 初期設定
        if self.mode == Mode.INIT:
            # インスタンス変数のリセット
            self.reset()
            self.score = 0
            self.time = Game.max_time

            self.mode = Mode.POW_X

        # 横パワー決定待ち
        elif self.mode == Mode.POW_X:
            # 残り時間更新
            self.time -= delta
            if self.time <= 0:
                self.time = 0
                self.wait_timer = Game.waittime_end
                self.mode = Mode.TIME_UP
                return
            # 横パワー更新
            else:
                self.pow_x += 1
                if Game.max_pow_x < self.pow_x:
                    self.pow_x = 0
                if self.decide:
                    self.mode = Mode.POW_Y

        # 縦パワー決定待ち
        elif self.mode == Mode.POW_Y:
            # 残り時間更新
            self.time -= delta
            if self.time <= 0:
                self.time = 0
                self.wait_timer = Game.waittime_end
                self.mode = Mode.TIME_UP
            # 縦パワー更新
            else:
                self.pow_y += 1
                if Game.max_pow_y < self.pow_y:
                    self.pow_y = 0

                if self.decide:
                    self.ball.kick(self.pow_x, self.pow_y)
                    self.mode = Mode.FLYING

        # ボール飛行中
        elif self.mode == Mode.FLYING:
            self.ball.update(delta, self.stage.wind)

            # 的に接触
            if self.target.is_hit(self.ball.x, self.ball.y):
                self.wait_timer = Game.waittime_hit
                self.mode = Mode.WAIT_HIT

            # 地面に接触
            elif Game.scr_h - Game.ground_h <= self.ball.y:
                self.ball.y = Game.scr_h - Game.ground_h
                self.wait_timer = Game.waittime_hit
                self.mode = Mode.WAIT_OUT

        # ヒット演出待ち
        elif self.mode == Mode.WAIT_HIT:
            self.wait_timer -= delta
            if self.wait_timer <= 0:
                self.score += int(self.ball.time)
                self.reset()
                self.mode = Mode.POW_X

        # 外れ演出待ち
        elif self.mode == Mode.WAIT_OUT:
            self.wait_timer -= delta
            if self.wait_timer <= 0:
                self.reset()
                self.mode = Mode.POW_X

        # タイムアップ演出待ち
        elif self.mode == Mode.TIME_UP:
            self.wait_timer -= delta
            if self.wait_timer <= 0:
                self.wait_timer = 0
                self.mode = Mode.RESULT

        # リザルト
        elif self.mode == Mode.RESULT:
            if self.decide:
                self.mode = Mode.INIT

        self.decide = False

    # 決定操作
    def send_decide(self):
        self.decide = True

    # ボール飛行中か
    def is_flying(self):
        return self.mode == Mode.FLYING

    # ヒット演出中か
    def is_hit(self):
        return self.mode == Mode.WAIT_HIT

    # 時間切れか
    def is_timeup(self):
        return self.mode == Mode.TIME_UP

    # リザルト表示中か
    def is_result(self):
        return self.mode == Mode.RESULT


# ===========================
# ステージ
# ===========================
class Stage:
    # クラス変数
    wind_range = (-10, 10)  # 風速の範囲

    # コンストラクタ
    def __init__(self, ground_h):
        self.ground_h = ground_h  # 地面の高さ
        self.wind = 0

    # 風速の再設定
    def reset_wind(self):
        (min, max) = Stage.wind_range
        self.wind = random.randint(min, max)


# ===========================
# ボール
# ===========================
class Ball:
    # クラス変数
    size = 10  # 大きさ
    gravity = 0.5  # ボールにかかる重力
    wind_rate = 0.012  # 風の適用率
    pow_rate_x = 0.15  # パワー横の適用率
    pow_rate_y = 0.20  # パワー縦の適用率

    # コンストラクタ
    def __init__(self):
        self.reset()

    # リセット
    def reset(self, x=0, y=0):
        self.x = x  # X座標
        self.y = y  # Y座標
        self.sx = 0  # X速さ
        self.sy = 0  # Y速さ
        self.time = 0  # 飛行時間

    # キック
    def kick(self, pow_x, pow_y):
        self.sx = pow_x * Ball.pow_rate_x
        self.sy = pow_y * -Ball.pow_rate_y
        self.time = 0

    # 更新
    def update(self, delta, wind):
        self.sx += wind * Ball.wind_rate
        self.sy += Ball.gravity
        self.x += self.sx
        self.y += self.sy
        self.time += delta * 10


# ===========================
# 的
# ===========================
class Target:
    # クラス変数
    size_w = 20
    size_h = 100

    # コンストラクタ
    def __init__(self):
        self.set_pos(0, 0)

    # 座標設定
    def set_pos(self, x, y):
        self.x = x
        self.y = y

    # 当たり判定
    def is_hit(self, x, y):
        if self.x < x and x < self.x + Target.size_w and self.y < y and y < self.y + Target.size_h:
            return True
        return False

このコードに対して、リファクタリングを行っていきます。

コメントをdocstring形式にする

まず気になったのはコメント部分。

# ===========================
# 的
# ===========================
class Target:
    # クラス変数
    size_w = 20

    # 座標設定
    def set_pos(self, x, y):
        self.x = x

こんな感じでクラスの前にコードの「土手」を作っていますが、これは完全に我流です。

また、クラスや関数に対してのコメントも普通のコメント(#)を使っていますが、推奨されるコメント規約があるので、そちらのスタイルを参考にします。
(引数の説明など、厳密には合わせていない部分もあります)

対応したのが以下のコードです。

class Target:
    """的."""

    #: 幅
    size_w = 20

    def set_pos(self, x, y):
        """座標設定."""
        self.x = x

モジュールを分割する

ゲームモデル全体が一つのファイルなので、コードがちょっと長くなっています。

このあたりは明確な正解がないのですが、今回はクラスごとに.pyファイルを分けることにしました。

モジュール分割後の構成

ユニットテストを書く

リファクタリングを行うにあたって重要とされるものにユニットテストが挙げられます。

ユニットテストとは、モジュール・クラス・関数などが意図通りかを確認するために書くコードです。

製品には含まれませんが、コードの品質を保証するために重要なものです。
(GitHubなどで公開されているケースでは、testsというフォルダ以下にユニットテスト用のコードが含まれていることが多いです)

この規模のゲームであれば組み込み箇所で確認するのでも十分なのですが、今回は練習も兼ねてやってみます。

Pythonのユニットテスト用ライブラリはいくつかありますが、今回はunittestを使います。

"""targetモジュールのテスト."""

import unittest


class TestTarget(unittest.TestCase):

    def setUp(self) -> None:
        super().setUp()
        self.target = Target(size=Size(20, 60))

    def test_init(self):
        self.assertEqual(self.target.size, Size(20, 60))

この例では、「Targetクラスの生成時に渡したサイズ情報が正しく適用されているか」を確認しています。

self.assertEqual(a, b) は、aとbが等しければテストOK という意味です。

このテストを実行すると、以下のような結果が出ます。

============================= test session starts =============================
collecting ... collected 1 items

test_target.py::TestTarget::test_init PASSED                             [ 100%]


============================== 1 passed in 0.02s ==============================

逆に失敗するとこんな感じ。

test_target.py::TestTarget::test_init FAILED                             [100%]
test_target.py:17 (TestTarget.test_init)
<shahou.app.model.values.Size object at 0x0000022B44BB4FA0> != <shahou.app.model.values.Size object at 0x0000022B44BB4F70>

期待:<shahou.app.model.values.Size object at 0x0000022B44BB4F70>
実際:<shahou.app.model.values.Size object at 0x0000022B44BB4FA0>

Targetクラスに手を入れる度にこのテストを実行することで、動作に変更が無いかを確認できます。

特にゲームを起動してシーケンスを進めないと確認できないようなクラスがあった場合、毎回起動してそこまで進むのは大変です。

もちろん起動しての確認も必須ですが、テストOKになった場合のみ起動して確認することで、確認の手間を減らすことができます。

メンバー変数を読み取り専用にする

現状、メンバー変数は全てpublicになっており、クラス外から好きなように参照・設定できます。

参照だけならともかく、設定できてしまうとバグが起きた際に「どこで値が変更されているかわからない!」ということになりますので、参照だけできるようにします。

具体的には以下のようにします。

  1. メンバー変数をprivate化する
  2. 参照が必要な変数に@propertyをつける

これをやったのが以下のコードです。

class Target:
    """的."""

    def __init__(self):
        self._pos: Position = Position(0, 0)

    @property
    def position(self) -> Position:
        """位置."""
        return self._pos

self._posのように変数の先頭に「_」を付けることで、「クラス外から直接アクセスしないでね」と宣言します。
※Pythonの場合、実際には無理矢理アクセスできてしまうのですが、エディタ上で警告を出してくれたりするので、それでもOKとします。

外部から_posの値を参照したい場合は、target.position とすれば参照できます。

逆に target.position = pos のように設定しようとするとエラーが出ます。

これで読み手に「positionの変化を追うなら、Targetクラスの中だけを見ればいいのね」と伝えることができます。

このように、チームでコードを書く際には「他の人が見なきゃいけない範囲を以下に狭くするか」ということが大切です。

「他の人」というのは、「3ヶ月後の自分」も含まれるかもしれません💦

型ヒントをつける

Pythonには型ヒント(タイプアノテーションともいう)があり、これを付けることで変数や引数の型を明示できます。

型を明示することでコードが読みやすくなり、mypy などの解析ツールを使用したチェックも行えるようになります。

def kick(self, pow_x: float, pow_y: float) -> None:
    """キック."""
    self.sx, self.sy = self._power_ratio * (pow_x, pow_y)
    self._time = 0

↑の例では、kickの引数pow_xとpow_yがどちらもfloat型であり、戻り値が無いことを明示しています。

例えばこの関数の最後に return 0 と書き加えた場合、mypy実行時に以下のエラーが出ます。

-> None を書いていないとこのエラーは出ません。

これにより、意図しない値をreturnしたり、し忘れたりするのを防ぐことができます。

値オブジェクトの導入

現状、的のサイズを表す変数は int 型になっています。

    size_w: int = 20
    size_h: int = 100

int型はマイナスの値も含むので、サイズの変数に「-5」などの値を渡すこともできてしまいます。

「渡さなければいいじゃない」と思うかもしれませんが、例えば他の関数で計算されたマイナス値が意図せず渡されてしまうケースもあります。

使用する側でサイズ値を渡す前にチェックを行うこともできますが、使用箇所が増えた場合に同じようなチェックコードが各所に書かれることになります。

この問題を解決するため、ちょっと前に読んだドメイン駆動設計の本に出てきた値オブジェクトを導入することにします。

値オブジェクトとは、値をラッピングしたクラスのことです。

例えばサイズの場合は以下のようになります。

class Size:
    """大きさ."""

    def __init__(self, width: int, height: int) -> None:
        if width <= 0:
            raise ValueError
        self._width = width

        if height <= 0:
            raise ValueError
        self._height = height

使う側のコードはこう。

size: Size = Size(10, 20)

initの内部で、引数に対してエラーチェック(マイナスならエラー)を行っています。

またSizeクラスにはサイズを変更するためのメソッドを用意していない(読み込み専用のプロパティのみ)ので、生成後に外部からサイズが変更される恐れもありません。

エラーなしに生成できたsizeの値は、必ず正しいことが保証されます

この値オブジェクトを各所で利用していくことで、安定かつ読みやすいコードになります。

ただし以下のデメリットもあります。

  • コードを書く量が増える
  • パフォーマンスが落ちる

特にゲーム開発においてはパフォーマンスは重要なので、実際の現場ではあまりできないやり方かもしれません。

※実践している例もあります

テスト同様、今回は検証がてらやってみます。

ロジックをクラス内に閉じ込める

以下のコード。

self.wait_timer -= delta
if self.wait_timer <= 0:
    # 処理

ヒット演出の終了待ち(wait_timerが0になるのを待つ)をしている部分ですが、生の値をそのまま使用しています。

wait_timerを使う場所で毎回似たような処理を書かなければいけなくなります。

そこで、前述の値オブジェクトでTimerクラスを作り、クラス内に以下のようなメソッドを追加します。

class Timer:
    """タイマー."""

    def __init__(self, seconds: float) -> None:
        if seconds < 0:
            raise ValueError
        self._time = seconds

    def update(self, delta: float) -> None:
        """タイマーの更新."""
        if delta < 0:
            raise ValueError
        self._time -= delta
        self._time = max(self._time, 0)

    def is_end(self) -> bool:
        """終了しているか."""
        return math.isclose(self._time, 0)

使う側のコードは以下のようになります。

self._wait_timer.update(delta)
if self._wait_timer.is_end():
    # 処理

これにより、Timerクラスを使う側は内部の細かい計算処理のことを知らなくても、

updateにdeltaを渡して、is_end()がTrueになるまで待てばいいんだな

とだけ把握しておけば作業ができるようになります。

仮にupdateやis_endメソッドの実装が変わったとしても、引数や戻り値を変えない限り使用する側への影響はありません。

リファクタリング後のコード

以上のことを踏まえて書き直したコードが以下です。
※コード量が増えたので、一部のファイルのみ抜粋しています。

ゲーム本体(game.py)

"""ゲームモデル."""

from __future__ import annotations

from enum import Enum

from shahou.app.model.ball import Ball, PowerRatio
from shahou.app.model.power import Power
from shahou.app.model.stage import Stage
from shahou.app.model.target import Target
from shahou.app.model.values import (
    Timer,
    Score,
    Size,
    Position,
    Ratio,
)


class Mode(Enum):
    """ゲームモード."""

    INIT = 0  # 初期
    POW_X = 1  # Xパワー決定待ち
    POW_Y = 2  # Yパワー決定待ち
    FLYING = 3  # 飛行中
    WAIT_HIT = 4  # ヒット演出中
    WAIT_OUT = 5  # 外れ演出中
    TIME_UP = 6  # 時間切れ演出中
    RESULT = 7  # リザルト


class Game:
    """ゲーム本体."""

    #: 制限時間
    _MAX_TIME: float = 60
    #: ヒット演出時間
    _WAIT_TIME_HIT: float = 1.0
    #: タイムアップ演出時間
    _WAIT_TIME_END: float = 2.0

    #: 的の最小位置
    _TARGET_POS_MIN: Position = Position(150, 100)
    #: 的の最大位置
    _TARGET_POS_MAX: Position = Position(350, 200)
    #: 的のサイズ
    _TARGET_SIZE: Size = Size(20, 80)

    #: ボール初期位置
    _BALL_POS_INIT: Position = Position(20, 25)
    #: ボールサイズ
    _BALL_SIZE: int = 6
    #: ボールにかかる重力
    _BALL_GRAVITY: float = 0.4
    #: 風の適用率
    _BALL_WIND_RATE: Ratio = Ratio(0.01)
    #: パワーの適用率
    _BALL_POWER_RATIO: PowerRatio = PowerRatio(0.08, 0.2)

    #: 地面の高さ
    _GROUND_H: int = 20

    def __init__(self) -> None:
        self._stage = Stage(Game._GROUND_H)
        self._target = Target(
            size=Game._TARGET_SIZE,
            min_pos=Game._TARGET_POS_MIN,
            max_pos=Game._TARGET_POS_MAX)
        self._power: Power = Power()
        self._timer: Timer = Timer(0)
        self._wait_timer: Timer = Timer(0)  # 演出タイマー
        self._reset()
        self._mode = Mode.INIT  # モード番号
        self._decide = False  # 決定操作がされたか
        self._score = Score(0)

        # update関数テーブル
        self.update_dictionary = {
            Mode.INIT: self._update_init,
            Mode.POW_X: self._update_pow_x,
            Mode.POW_Y: self._update_pow_y,
            Mode.FLYING: self._update_flying,
            Mode.WAIT_HIT: self._update_wait_hit,
            Mode.WAIT_OUT: self._update_wait_out,
            Mode.TIME_UP: self._update_timeup,
            Mode.RESULT: self._update_result,
        }

    @property
    def score(self) -> Score:
        """スコア."""
        return self._score

    @property
    def time(self) -> float:
        """残り時間."""
        return self._timer.time

    @property
    def target(self) -> Target:
        """的."""
        return self._target

    @property
    def stage(self) -> Stage:
        """ステージ."""
        return self._stage

    @property
    def power(self) -> Power:
        """パワー."""
        return self._power

    @property
    def ball(self) -> Ball:
        """ボール."""
        return self._ball

    def _reset(self) -> None:
        """状態をリセットする."""
        self._ball = Ball(
            size=Game._BALL_SIZE,
            power_ratio=Game._BALL_POWER_RATIO,
            gravity=Game._BALL_GRAVITY,
            wind_rate=Game._BALL_WIND_RATE)
        self._ball.reset(Game._BALL_POS_INIT)
        self._power.reset()
        self._stage.reset_wind()
        self._target.move_random()

    def _update_init(self, delta: float) -> None:
        """更新:初期設定."""
        self._reset()
        self._score = Score(0)
        self._timer = Timer(Game._MAX_TIME)
        self._mode = Mode.POW_X

    def _update_pow_x(self, delta: float) -> None:
        """更新:横パワー決定待ち."""
        # 残り時間更新
        self._timer.update(delta)
        if self._timer.is_end():
            self._wait_timer = Timer(Game._WAIT_TIME_END)
            self._mode = Mode.TIME_UP
        # 横パワー更新
        else:
            self._power.increase_x()
            if self._decide:
                self._mode = Mode.POW_Y

    def _update_pow_y(self, delta: float) -> None:
        """更新:縦パワー決定待ち."""
        # 残り時間更新
        self._timer.update(delta)
        if self._timer.is_end():
            self._wait_timer = Timer(Game._WAIT_TIME_END)
            self._mode = Mode.TIME_UP
        # 縦パワー更新
        else:
            self._power.increase_y()
            if self._decide:
                self._ball.kick(self._power.x, self._power.y)
                self._mode = Mode.FLYING

    def _update_flying(self, delta: float) -> None:
        """更新:ボール飛行中."""
        self._ball.update(delta, self._stage.wind)

        # 的に接触
        if self._target.is_hit(self._ball.pos):
            self._wait_timer = Timer(Game._WAIT_TIME_HIT)
            self._mode = Mode.WAIT_HIT
        # 地面に接触
        elif self._is_ball_on_ground(self._ball):
            self._ball.attach_bottom(Game._GROUND_H)
            self._wait_timer = Timer(Game._WAIT_TIME_HIT)
            self._mode = Mode.WAIT_OUT

    @staticmethod
    def _is_ball_on_ground(ball: Ball):
        """ボールが地面に触れたか."""
        return ball.bottom <= Game._GROUND_H

    def _update_wait_hit(self, delta: float) -> None:
        """更新:ヒット演出待ち."""
        self._wait_timer.update(delta)
        if self._wait_timer.is_end():
            self._score.add(self._balltime_to_score())
            self._reset()
            self._mode = Mode.POW_X

    def _balltime_to_score(self) -> Score:
        """ボールの滞空時間からスコアを得る."""
        return Score(int(self._ball.time))

    def _update_wait_out(self, delta: float) -> None:
        """更新:外れ演出待ち."""
        self._wait_timer.update(delta)
        if self._wait_timer.is_end():
            self._reset()
            self._mode = Mode.POW_X

    def _update_timeup(self, delta: float) -> None:
        """更新:タイムアップ演出待ち."""
        self._wait_timer.update(delta)
        if self._wait_timer.is_end():
            self._wait_timer = Timer(0)
            self._mode = Mode.RESULT

    def _update_result(self, delta: float) -> None:
        """更新:リザルト."""
        if self._decide:
            self._mode = Mode.INIT

    def update(self, delta: float) -> None:
        """更新."""
        func = self.update_dictionary[self._mode]
        func(delta)

        self._decide = False

    def send_decide(self) -> None:
        """決定操作."""
        self._decide = True

    def is_flying(self) -> bool:
        """ボール飛行中か."""
        return self._mode == Mode.FLYING

    def is_hit(self) -> bool:
        """ヒット演出中か."""
        return self._mode == Mode.WAIT_HIT

    def is_timeup(self) -> bool:
        """時間切れか."""
        return self._mode == Mode.TIME_UP

    def is_result(self) -> bool:
        """リザルト表示中か."""
        return self._mode == Mode.RESULT

的(target.py)

"""的."""

import random

from shahou.app.model.values import Position, Size


class Target:
    """的."""

    def __init__(
            self,
            size: Size,
            min_pos: Position,
            max_pos: Position) -> None:
        self._size = size
        self._min_pos = min_pos
        self._max_pos = max_pos
        self._pos: Position = Position(0, 0)

    @property
    def position(self) -> Position:
        """位置."""
        return self._pos

    @property
    def size(self) -> Size:
        """サイズ."""
        return self._size

    def move_random(self) -> None:
        """位置をランダムで移動させる."""
        self._pos = Position(
            x=random.randint(self._min_pos.x, self._max_pos.x),
            y=random.randint(self._min_pos.y, self._max_pos.y))

    def is_hit(self, position: Position) -> bool:
        """地点(x, y)と衝突しているか."""
        left = self._pos.x
        right = self._pos.x + self._size.width
        top = self._pos.y
        bottom = self._pos.y + self._size.height

        contain_x = (left <= position.x <= right)
        contain_y = (top <= position.y <= bottom)
        if contain_x and contain_y:
            return True
        return False

値オブジェクトたち(values.py)

"""値オブジェクトたち."""

from __future__ import annotations

import math


class Ratio:
    """割合."""

    def __init__(self, ratio: float) -> None:
        if ratio < 0:
            raise ValueError
        self._ratio: float = ratio

    @property
    def ratio(self) -> float:
        return self._ratio

    def __eq__(self, other) -> bool:
        """等価比較."""
        if isinstance(other, Ratio):
            return math.isclose(self._ratio, other.ratio)
        elif isinstance(other, float):
            return math.isclose(self._ratio, other)
        return False

    def __mul__(self, other):
        """乗算."""
        if isinstance(other, Ratio):
            return self._ratio * other.ratio
        return self._ratio * other


class Position:
    """座標."""

    def __init__(self, x: float, y: float) -> None:
        self._x = x
        self._y = y

    @property
    def x(self) -> float:
        """X座標."""
        return self._x

    @property
    def y(self) -> float:
        """Y座標."""
        return self._y

    def get(self) -> tuple[float, float]:
        """タプルでまとめて取得"""
        return self._x, self._y

    def move(self, x: float, y: float) -> Position:
        """x, yだけ移動した座標を得る."""
        return Position(self._x + x, self._y + y)

    def __eq__(self, other) -> bool:
        """等価比較."""
        if isinstance(other, Position):
            return self._x == other.x and self._y == other.y
        elif isinstance(other, tuple):
            (x, y) = other
            return self._x == x and self._y == y
        return False


class Size:
    """大きさ.

    :param width:
    """

    def __init__(self, width: int, height: int) -> None:
        if width <= 0:
            raise ValueError
        self._width = width

        if height <= 0:
            raise ValueError
        self._height = height

    @property
    def width(self) -> int:
        """幅."""
        return self._width

    @property
    def height(self) -> int:
        """高さ."""
        return self._height

    def get(self) -> tuple[int, int]:
        """タプルでまとめて取得する."""
        return self._width, self._height

    def __eq__(self, other) -> bool:
        """等価比較."""
        if isinstance(other, Size):
            return self._width == other.width and self._height == other.height
        elif isinstance(other, tuple):
            (w, h) = other
            return self._width == w and self._height == h
        return False


class Timer:
    """タイマー.

    :param seconds: 秒数
    """

    def __init__(self, seconds: float) -> None:
        if seconds < 0:
            raise ValueError
        self._time = seconds

    def update(self, delta: float) -> None:
        """タイマーの更新."""
        if delta < 0:
            raise ValueError
        self._time -= delta
        self._time = max(self._time, 0)

    @property
    def time(self) -> float:
        """残り時間."""
        return self._time

    def is_end(self) -> bool:
        """終了しているか."""
        return math.isclose(self._time, 0)


class Score:
    """スコア."""

    def __init__(self, score: int) -> None:
        if score < 0:
            raise ValueError
        self._score = score

    def add(self, score: Score) -> None:
        """加算."""
        self._score += score.value

    @property
    def value(self) -> int:
        """値の取得."""
        return self._score

    def __str__(self):
        return str(self._score)

    def __eq__(self, other):
        if isinstance(other, Score):
            return self.value == other.value
        return self.value == other

まとめ

既存のコードをリファクタリングしていく過程をお伝えしました。

値オブジェクトとかは正直やり過ぎな気もしますが、考え方の一つとして参考にしていただければと思います。

コメント

タイトルとURLをコピーしました