【Python】自作マリオ 敵を倒す処理と当たり判定

[参考：【#6 敵を踏み潰す】クリボーを踏み潰してみよう！そしてマリオも死ぬよ！マリオを一緒に作ってみませんか！pythonで！【ハンズオン実践解説】](https://youtu.be/QkrMK9gSvoU?si=-xP5pQLsgRDKn781)

## **1. マリオとクリボーの状態管理を Enum で統一**
### **変更点**
- `Status` クラスを `Enum` で定義。
- `NORMAL`, `DEADING`, `DEAD` の 3 つの状態を追加。
```python
from enum import Enum, auto
import time

class Status(Enum):
    NORMAL = auto()
    DEADING = auto()
    DEAD = auto()

```

### **解説**
- `Enum` を導入することで、マリオやクリボーの状態管理がわかりやすくなった。
- `DEADING` (死亡アニメーション中) → `DEAD` (完全に死亡) という状態遷移を明確化。

---

## **2. マリオの死亡アニメーションの追加**
### **変更点**
- `self.__status = Status.NORMAL` を追加。
- `__deading()` メソッドを追加し、死亡アニメーションを実装。

```python
        # マリオの状態
        self.__status = Status.NORMAL
```
```python
    def __deading(self):
        if self.__animecounter == 0:
            self.__vy = -12
        
        if self.__animecounter > 10:
            self.__vy += 1
            self.rect.y += self.__vy
        
        if self.rect.y > H + 20:
            self.__status = Status.DEAD
            return

        self.__animecounter += 1
```

### **解説**
- マリオが `DEADING` 状態になると、上にジャンプ (`vy = -12`) した後、徐々に落下する処理を実装。
- 落下しきる (`rect.y > H + 20`) と `Status.DEAD` に遷移し、ゲームオーバー処理に移行。

---

## **3. クリボーとの衝突判定**
### **変更点**
- `if self.rect.colliderect(self.__mario.rect):` により、マリオとクリボーの接触を検出。
- `if self.__mario.vy > 0:` で踏みつけた場合 (`DEADING` 状態に変更)、そうでない場合はマリオが `DEADING` に遷移。

```python
        if self.rect.colliderect(self.__mario.rect):
            # 当たった時の処理をする
            if self.__mario.vy > 0:
                # 踏みつぶす
                self.__status = Status.DEADING
                self.__mario.vy = -5
            else:
                self.__mario.status = Status.DEADING
```

### **解説**
- 踏みつける (`vy > 0`) とクリボーは `DEADING` になり、30フレーム後 `DEAD` に遷移。
- 逆に横や下から衝突すると、マリオが `DEADING` に。

---

## **4. クリボーのつぶれアニメーション**
### **変更点**
- `self.__collapsecount = 0` を追加し、踏みつけ後のアニメーション用カウンタを導入。
- `self.image = self.__imgs[1]` でつぶれた画像を表示。
```python
        # つぶれたカウンタ
        self.__collapsecount = 0
```
```python
def update(self):
        if self.__mario.status == Status.DEADING:
            return
        if self.__status == Status.DEADING:
            self.image = self.__imgs[1]
            self.__collapsecount += 1
            if self.__collapsecount == 30:
                self.__status = Status.DEAD
            return
        if self.__status == Status.DEAD:
            return

```


### **解説**
- 30フレーム (`collapsecount == 30`) 経過後に `DEAD` へ遷移し、`group.remove(kuribo)` で消滅。

---

## **5. `init()` 関数の追加**
### **変更点**
- 初期化処理 (`mario` と `kuribo` の生成) を `init()` に分離。

```python
def init():
    # スプライトグループを定義
    group = pygame.sprite.RenderUpdates()
    # マリオクラスを定義
    mario = Mario()
    # マリオをグループに追加
    group.add(mario)
    # クリボークラスを構築
    kuribos = [
        Kuribo(200, 180, mario),
        Kuribo(260, 180, mario),
        Kuribo(300, 180, mario)
    ]
    # クリボーをグループに追加
    for kuribo in kuribos:
        group.add(kuribo)
    
    return group, mario, kuribos

```
```python
        # マリオが死んだかのチェック
        if mario.status == Status.DEAD:
            time.sleep(2)
            group, mario, kuribos = init()
            continue
```
### **解説**
- `mario.status == Status.DEAD` になると `time.sleep(2)` して `init()` を呼び出し、ゲームをリセット。
- `main()` 内のコードが整理され、可読性が向上。

## **6. `@property` を活用したカプセル化**
### **変更点**
- `vy` と `status` を `@property` で管理。

```python
    @property
    def vy(self):
        return self.__vy

    @vy.setter
    def vy(self, value):
        self.__vy = value

    @property
    def status(self):
        return self.__status

    @status.setter
    def status(self, value):
        self.__status = value

```

### **解説**
- `mario.vy` への直接アクセスを防ぎ、統一的な `setter/getter` を提供。
- オブジェクト指向的な設計に改良。

## 完全なコード
```Python
import pygame
from enum import Enum, auto
import time

class Status(Enum):
    NORMAL = auto()
    DEADING = auto()
    DEAD = auto()

# 画面サイズを定義
W, H = 320, 270

# タイル数
TILE_X = 16
TILE_Y = 14

class Mario(pygame.sprite.Sprite):
    ''' マリオのクラス
    '''
    WALK_ANIME_IDX = [0, 0, 0, 1, 1, 1]
    def __init__(self):
        pygame.sprite.Sprite.__init__(self)

        # 左右どちら向きかのフラグを定義
        self.__isleft = False
        # 歩くインデックス
        self.__walkidx = 0
        # Y軸方向移動郷里
        self.__vy = 0
        # マリオが地面にいるか
        self.__on_ground = True
        # マリオの状態
        self.__status = Status.NORMAL
        # アニメ用カウンタ
        self.__animecounter = 0

        # marioの画像を読み込む
        self.__imgs = [
            pygame.image.load('mario001.png'),
            pygame.image.load('mario002.png'),
            pygame.image.load('mario003.png')
        ]
        self.image = self.__imgs[0]
        self.rect = pygame.Rect(150, 180, 20, 20)

    @property
    def vy(self):
        return self.__vy

    @vy.setter
    def vy(self, value):
        self.__vy = value

    @property
    def status(self):
        return self.__status

    @status.setter
    def status(self, value):
        self.__status = value

    def __right(self):
        self.rect.x += 5
        self.__isleft = False
        self.__walkidx += 1

    def __left(self):
        self.rect.x -= 5
        self.__isleft = True
        self.__walkidx += 1
    
    def __jump(self):
        if self.__on_ground:
            self.__vy = -10
            self.__on_ground = False

    def __deading(self):
        if self.__animecounter == 0:
            self.__vy = -12
        
        if self.__animecounter > 10:
            self.__vy += 1
            self.rect.y += self.__vy
        
        if self.rect.y > H + 20:
            self.__status = Status.DEAD
            return

        self.__animecounter += 1

    def update(self):
        if self.__status == Status.DEAD:
            return

        if self.__status == Status.DEADING:
            self.image = self.__imgs[2]
            self.__deading()
            return

        # キーボードの状態を取得
        keys = pygame.key.get_pressed()
        if keys[pygame.K_RIGHT]:
            self.__right()
        if keys[pygame.K_LEFT]:
            self.__left()
        if keys[pygame.K_SPACE]:
            self.__jump()

        # Y軸方向に移動
        if not self.__on_ground:
            self.rect.y += self.__vy
            self.__vy += 1

            if self.rect.y >= 180:
                self.rect.y = 180
                self.__on_ground = True
                self.__vy = 0

        self.image = pygame.transform.flip(self.__imgs[self.WALK_ANIME_IDX[self.__walkidx % 6]], self.__isleft, False)

class Kuribo(pygame.sprite.Sprite):
    WALK_SPEED = 10
    def __init__(self, x, y, mario):
        pygame.sprite.Sprite.__init__(self)

        # クリボーのX軸方向移動距離
        self.__dir = -2
        self.__walkidx = 0
        self.__imgs = [
            pygame.image.load('kuribo001.png'),
            pygame.image.load('kuribo002.png')
        ]
        self.__mario = mario
        self.__status = Status.NORMAL
        # つぶれたカウンタ
        self.__collapsecount = 0

        self.image = self.__imgs[0]
        self.rect = pygame.Rect(x, y, 20, 20)
    
    @property
    def status(self):
        return self.__status

    def update(self):
        if self.__mario.status == Status.DEADING:
            return
        if self.__status == Status.DEADING:
            self.image = self.__imgs[1]
            self.__collapsecount += 1
            if self.__collapsecount == 30:
                self.__status = Status.DEAD
            return
        if self.__status == Status.DEAD:
            return

        self.rect.x += self.__dir
        if self.rect.x <= 0 or self.rect.x >= 320 - self.rect.width:
            self.__dir *= -1

        self.__walkidx += 1
        if self.__walkidx == self.WALK_SPEED:
            self.__walkidx = 0
        self.image = pygame.transform.flip(self.__imgs[0], self.__walkidx < self.WALK_SPEED // 2, False)

        if self.rect.colliderect(self.__mario.rect):
            # 当たった時の処理をする
            if self.__mario.vy > 0:
                # 踏みつぶす
                self.__status = Status.DEADING
                self.__mario.vy = -5
            else:
                self.__mario.status = Status.DEADING

def init():
    # スプライトグループを定義
    group = pygame.sprite.RenderUpdates()
    # マリオクラスを定義
    mario = Mario()
    # マリオをグループに追加
    group.add(mario)
    # クリボークラスを構築
    kuribos = [
        Kuribo(200, 180, mario),
        Kuribo(260, 180, mario),
        Kuribo(300, 180, mario)
    ]
    # クリボーをグループに追加
    for kuribo in kuribos:
        group.add(kuribo)
    
    return group, mario, kuribos


def main():
    '''メイン関数'''
    # pygame初期化
    pygame.init()
    # 画面を構築
    win = pygame.display.set_mode((W, H))
    pygame.display.set_caption("Pygame Window")  # ウィンドウタイトルを設定
    # クロックを生成
    clock = pygame.time.Clock()

    group, mario, kuribos = init()

    # 背景色の初期値（黒）
    bg_color = (0, 0, 0)

    # イベントループ
    running = True
    while running:
        for e in pygame.event.get():
            if e.type == pygame.QUIT:
                running = False  # ループを抜ける

        # 背景を塗りつぶす
        win.fill((135, 206, 235))

        # グループを更新
        group.update()
        
        # マリオが死んだかのチェック
        if mario.status == Status.DEAD:
            time.sleep(2)
            group, mario, kuribos = init()
            continue

        # 死んでる敵をグループから削除
        for kuribo in kuribos:
            if kuribo.status == Status.DEAD:
                group.remove(kuribo)

        # グループを描画
        group.draw(win)

        # 画面の更新
        pygame.display.flip()

        clock.tick(30)  # FPS制限 (60FPS)

    # pygameを終了する
    pygame.quit()

# Pythonのファイルが直接実行された場合のみmain()を実行
if __name__ == '__main__':
    main()

```

## **まとめ**
| 変更点 | 修正前 | 修正後 |
|--------|--------|--------|
| 状態管理 | `bool` で管理 (`__on_ground` など) | `Status` Enum を導入 |
| マリオの死亡処理 | なし | `DEADING` → `DEAD` のアニメーション追加 |
| クリボーの衝突処理 | なし | `colliderect()` を使い、踏みつけと接触で処理を分岐 |
| クリボーのつぶれアニメ | なし | `__collapsecount` で 30フレーム後に削除 |
| ゲームリセット | なし | `time.sleep(2)` 後に `init()` でリセット |
| カプセル化 | `self.__vy` など直接操作 | `@property` を使用 |

![Pygame Window 2025-02-03 17-57-07.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3782903/0f9e7276-7b2c-92cd-f54a-1848765b5785.gif)