Pythonで作るスネークゲーム

## はじめに
　今回はPythonで、ヘビ状のオブジェクトにエサを食べさせて成長させる「スネークゲーム」を作ってみたいと思います。前回の記事で取り上げたPing Pongゲームに引き続き、Python初心者向けのゲームの一つです。Youtubeなど、ネットに多く教材がありますので、ぜひ参考にしてみてください。
　今回はpygameという、Pythonでゲーム作成をするときに便利なライブラリを使用しています。
今回のソースはこちらの動画に基づいて作成しています。
[“How to build SNAKE in Python!”](https://www.youtube.com/watch?v=9bBgyOkoBQ0)

## ゲーム概要と必要なオブジェクト及び処理
　そもそもスネークゲームとはどのようなゲームでしょうか。こちらが今回作るスネークゲームの外観です。

![スクリーンショット 2022-04-28 8.33.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1676583/5d931adc-8ed6-0dee-0f23-37ea6ece1f00.png)

マス目状のステージで、プレイヤーが黒いオブジェクト（スネーク）を操作し、ランダムに出現する黄色いオブジェクト（エサ）を食べ、どんどん成長していきます。スネークの体長が長くなって、方向転換をした時に自分の体にぶつかってしまうとゲームオーバーとなります。

## ゲームに必要な要素の洗い出し

　まずはマス目状の盤面に、登場人物となるスネーク、エサのオブジェクトを用意します。

```python
class Snake():
  def __init__(self):

  def get_head_position(self):

  def turn(self):

  def move(self):

  def reset(self):

  def draw(self):

  def handle_keys(self):


class Food():
  def __init__(self):

  def randomize_position(self):

  def draw(self, surface):


def drawGrid(surface):
```

　盤面を描く処理は一番下に記述したdrawGridのメソッドで行います。

　上記ソースコードで記載したように、スネークのオブジェクトに必要な処理は以下の通りです。
- 初期化処理
- 頭の位置を示す処理
- 方向転換する処理
- 動く処理
- ゲームオーバーになった時に大きさをリセットする処理
- スネークの姿を描き出す処理
- 上下左右キーとスネークの動きを結びつける処理

　一方、エサオブジェクトに必要な処理は以下の通りです。
- 初期化処理
- 出現するときのランダムな位置を指定する処理
- エサを描き出す処理

　それではそれぞれの処理の中身を実装していきましょう。

## スネークオブジェクトの処理
- 初期化処理
```python
def __init__(self):
    self.length = 1
    self.positions = [((SCREEN_WIDTH / 2), (SCREEN_HEIGHT / 2))]
    self.direction = random.choice([up, down, left, right])
    self.color = (17, 24, 47)
    self.score = 0
```

　この初期化処理ではスネークオブジェクトの大きさなど、デフォルトの設定を指定します。
　デフォルトの体長は1マス分なので「length = 1」を指定します。
　positionについては、今回は盤面中央に出現させるようにします。そのため盤面の縦横それぞれの半分の位置を座標で指定しています。
 directionは最初にスネークが出現したとき、最初にどの方向に走らせるかを指定します。[up, down, left, right]の中からランダムに選ばせます。[up, down, left, right]という4つの変数については後ほど指定します。
　colorはRGB形式で指定します。0~255の値でそれぞれ指定します。ちなみに4つ目の値として透明度を指定することもできるようです。
 scoreについては、エサを食べるたびに増えていくポイントの値を保持します。

- 頭の位置を示す処理
```python
def get_head_position(self):
    return self.positions[0]
```
　スネークの当たり判定をするときなどのために、頭の位置を取得する処理です。positions[0]が頭の部分になります。

- 方向転換する処理
```python
def turn(self, point):
    if self.length > 1 and (point[0] * -1, point[1] * -1) ==　self.direction:
        return
     else:
        self.direction = point
```
　if文の中では、スネークの大きさが1マスだけの時は4方向全てに方向転換できる旨が書かれています。else文では体長が2マス以上の時、真逆方向への転換ができない処理を書いています。

- 動く処理
```python
def move(self):
    cur = self.get_head_position()
    x, y = self.direction
    new = (((cur[0] + (x * GRIDSIZE)) % SCREEN_WIDTH), (cur[1] + (y * GRIDSIZE)) % SCREEN_HEIGHT)
    if len(self.positions) > 2 and new in self.positions[2:]:
        self.reset()
    else:
        self.positions.insert(0, new)
        if len(self.positions) > self.length:
            self.positions.pop()
```
　curに現在のスネークの頭の位置、xとyにスネークの進行方向を格納します。new変数の中に格納されているのはスネークの移動後の新しい座標です。
　最初のif文の条件はスネークの体長が2マス以上で、自身の体に進行先が重なってしまう場合を指します。つまりゲームオーバーになってしまう条件です。resetメソッドでゲームをリセットします。
　else文の中ではnew変数に格納された新しい頭の位置にマスを描画し、逆に尻尾の1マスを削除します。



- ゲームオーバーになった時に大きさをリセットする処理
```python
def reset(self):
    self.length = 1
    self.positions = [((SCREEN_WIDTH / 2), (SCREEN_HEIGHT / 2))]
    self.direction = random.choice([up, down, left, right])
    self.score = 0
```
　方向転換したときにスネークの頭が体に当たってしまったときに体長を1マス分に戻し、盤面の中央に位置取り、ランダムな進行方向を指定し、スコアをゼロに戻します。初期化処理に近い処理を行なっています。



- スネークの姿を描き出す処理
```python
def draw(self, surface):
    for p in self.positions:
        r = pygame.Rect((p[0], p[1]), (GRIDSIZE, GRIDSIZE))
        pygame.draw.rect(surface, self.color, r)
        pygame.draw.rect(surface, (93, 216, 218), r, 1)
```
　「positions」にはある時点のスネークの体の各部分の座標が格納されています。それぞれの部分について正方形の描画を行い、スネークの姿を描き出していきます。「surface」はのちに指定されていますが、ゲームの盤面自体を指します。盤面上に表示させるために指定します。「r」はエサの表示される範囲を表しています。



- 上下左右キーとスネークの動きを結びつける処理
```python
def handle_keys(self):
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()
            sys.exit()
        elif event.type == pygame.KEYDOWN:
            if event.key == pygame.K_UP:
                self.turn(up)
            elif event.key == pygame.K_DOWN:
                self.turn(down)
            elif event.key == pygame.K_LEFT:
                self.turn(left)
            elif event.key == pygame.K_RIGHT:
                self.turn(right)
```
　イベントが発生するたびにこの処理が行われます。イベントとはキーが押されたり、閉じるボタンが押されることを指しています。
　最初のif文で、もし閉じるボタンが押された場合はゲームを終了します。
　次のelif文からは、上キーが押された場合には方向転換処理に「up」を渡し、下キーが押された場合には方向転換処理に「down」を渡すというようなことをしています。


## エサオブジェクトの処理
- 初期化処理
```python
def __init__(self):
    self.position = (0, 0)
    self.color = (223, 163, 49)
    self.randomize_position()
```
スネークの初期化処理と同じく、色の指定や初期位置の設定をします。位置についてはまず(0, 0)を指定してはいますが、「self.randomize_position()」によって毎回ランダムな位置に出現することになります。

- 出現するときのランダムな位置を指定する処理
```python
def randomize_position(self):
    self.position = (random.randint(0, GRID_WIDTH - 1) * GRIDSIZE, random.randint(0, GRID_HEIGHT - 1) * GRIDSIZE)
```
　「random.randint(0, GRID_WIDTH - 1) 」によって、横幅について0から「GRID_WIDTH - 1」までのランダムな値が生成されます。「GRID_WIDTH」と「GRID_HEIGHT」は盤面のピクセル値での縦横サイズを示しており、のちにそれぞれ480を指定しています。「GRIDSIZE」は1マスの縦横の長さを示しており、のちに20を指定しています。
　ランダムに生成された座標を「self.position」に代入することで、最終的には毎回ランダムな座標が指定されることになります。

- エサを描き出す処理
```python
def draw(self, surface):
   r = pygame.Rect((self.position[0], self.position[1]), (GRIDSIZE, GRIDSIZE))
   pygame.draw.rect(surface, self.color, r)
```
　「pygame.Rect」の一つ目の引数に出現位置のx座標とy座標、二つ目の引数にオブジェクトサイズ（20×20）を指定しています。
　「surface」はのちに指定されていますが、ゲームの盤面自体を指します。盤面上に表示させるために指定します。「r」はエサの表示される範囲を表しています。


## 盤面描画の処理
```python
def drawGrid(surface):
   for y in range(0, int(GRID_HEIGHT)):
       for x in range(0, int(GRID_WIDTH)):
           if (x + y) % 2 == 0:
               r = pygame.Rect((x * GRIDSIZE, y * GRIDSIZE), (GRIDSIZE, GRIDSIZE))
               pygame.draw.rect(surface, (93, 216, 228), r)
           else:
               rr = pygame.Rect((x * GRIDSIZE, y * GRIDSIZE), (GRIDSIZE, GRIDSIZE))
               pygame.draw.rect(surface, (84, 194, 205), rr)
```
　2つのfor文で、それぞれ全てのy軸、x軸に対して処理を行う条件にします。
　「r」と「rr」2種類のパターンで描画することで、色の違うマス目が交互に描画されることになります。


## main処理
```python
SCREEN_WIDTH = 480
SCREEN_HEIGHT = 480

GRIDSIZE = 20
GRID_WIDTH = SCREEN_HEIGHT / GRIDSIZE
GRID_HEIGHT = SCREEN_WIDTH / GRIDSIZE

up = (0, -1)
down = (0, 1)
left = (-1, 0)
right = (1, 0)


def main():
   pygame.init()

   clock = pygame.time.Clock()
   screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT), 0, 32)

   surface = pygame.Surface(screen.get_size())
   surface = surface.convert()
   drawGrid(surface)

   snake = Snake()
   food = Food()

   myfont = pygame.font.SysFont("monospace", 16)

   while (True):
       clock.tick(10)
       snake.handle_keys()
       drawGrid(surface)
       snake.move()
       if snake.get_head_position() == food.position:
           snake.length += 1
           snake.score += 1
           food.randomize_position()
       snake.draw(surface)
       food.draw(surface)
       screen.blit(surface, (0, 0))
       text = myfont.render("Score {0}".format(snake.score), 1, (0, 0, 0))
       screen.blit(text, (5, 10))
       pygame.display.update()


main()
```
　ゲーム進行の大元となるmainメソッドの処理を見ていきます。まずは全てのオブジェクトのinitメソッドを走らせます。「pygame.display.set_mode」で、ゲームを実行するウインドウを作成します。「surface」はそのウインドウ全体を指しており、drawGridで最初の状態の盤面を描画します。
　while文の中の処理は「clock.tick(10)」とあるように、10ミリ秒毎に走ります。キー入力を読み取り、再び盤面を描画します。「snake.move()」でスネークを動かします。
　if文の中はスネークの頭の位置がエサの位置に一致した時、すなわちスネークがエサの確保に成功したときの処理です。スネークの体長と得点を1増やし、新たにエサが出現するランダムな位置を生成します。
　この段階で初めてスネークやエサを描画します。「screen.blit」でウインドウやスコア表示部分を表示します。最後に「display.update()」をして、描画内容を画面に反映させて終了です。


## ソースコード全貌

```python
import pygame
import sys
import random


class Snake():
   def __init__(self):
       self.length = 1
       self.positions = [((SCREEN_WIDTH / 2), (SCREEN_HEIGHT / 2))]
       self.direction = random.choice([up, down, left, right])
       self.color = (17, 24, 47)
       self.score = 0

   def get_head_position(self):
       return self.positions[0]

   def turn(self, point):
       if self.length > 1 and (point[0] * -1, point[1] * -1) == self.direction:
           return
       else:
           self.direction = point

   def move(self):
       cur = self.get_head_position()
       x, y = self.direction
       new = (((cur[0] + (x * GRIDSIZE)) % SCREEN_WIDTH), (cur[1] + (y * GRIDSIZE)) % SCREEN_HEIGHT)
       if len(self.positions) > 2 and new in self.positions[2:]:
           self.reset()
       else:
           self.positions.insert(0, new)
           if len(self.positions) > self.length:
               self.positions.pop()

   def reset(self):
       self.length = 1
       self.positions = [((SCREEN_WIDTH / 2), (SCREEN_HEIGHT / 2))]
       self.direction = random.choice([up, down, left, right])
       self.score = 0

   def draw(self, surface):
       for p in self.positions:
           r = pygame.Rect((p[0], p[1]), (GRIDSIZE, GRIDSIZE))
           pygame.draw.rect(surface, self.color, r)
           pygame.draw.rect(surface, (93, 216, 218), r, 1)

   def handle_keys(self):
       for event in pygame.event.get():
           if event.type == pygame.QUIT:
               pygame.quit()
               sys.exit()
           elif event.type == pygame.KEYDOWN:
               if event.key == pygame.K_UP:
                   self.turn(up)
               elif event.key == pygame.K_DOWN:
                   self.turn(down)
               elif event.key == pygame.K_LEFT:
                   self.turn(left)
               elif event.key == pygame.K_RIGHT:
                   self.turn(right)


class Food():
   def __init__(self):
       self.position = (0, 0)
       self.color = (223, 163, 49)
       self.randomize_position()

   def randomize_position(self):
       self.position = (random.randint(0, GRID_WIDTH - 1) * GRIDSIZE, random.randint(0, GRID_HEIGHT - 1) * GRIDSIZE)

   def draw(self, surface):
       r = pygame.Rect((self.position[0], self.position[1]), (GRIDSIZE, GRIDSIZE))
       pygame.draw.rect(surface, self.color, r)
       pygame.draw.rect(surface, (93, 216, 228), r, 1)


def drawGrid(surface):
   for y in range(0, int(GRID_HEIGHT)):
       for x in range(0, int(GRID_WIDTH)):
           if (x + y) % 2 == 0:
               r = pygame.Rect((x * GRIDSIZE, y * GRIDSIZE), (GRIDSIZE, GRIDSIZE))
               pygame.draw.rect(surface, (93, 216, 228), r)
           else:
               rr = pygame.Rect((x * GRIDSIZE, y * GRIDSIZE), (GRIDSIZE, GRIDSIZE))
               pygame.draw.rect(surface, (84, 194, 205), rr)


SCREEN_WIDTH = 480
SCREEN_HEIGHT = 480

GRIDSIZE = 20
GRID_WIDTH = SCREEN_HEIGHT / GRIDSIZE
GRID_HEIGHT = SCREEN_WIDTH / GRIDSIZE

up = (0, -1)
down = (0, 1)
left = (-1, 0)
right = (1, 0)


def main():
   pygame.init()

   clock = pygame.time.Clock()
   screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT), 0, 32)

   surface = pygame.Surface(screen.get_size())
   surface = surface.convert()
   drawGrid(surface)

   snake = Snake()
   food = Food()

   myfont = pygame.font.SysFont("monospace", 16)

   while (True):
       clock.tick(10)
       snake.handle_keys()
       drawGrid(surface)
       snake.move()
       if snake.get_head_position() == food.position:
           snake.length += 1
           snake.score += 1
           food.randomize_position()
       snake.draw(surface)
       food.draw(surface)
       screen.blit(surface, (0, 0))
       text = myfont.render("Score {0}".format(snake.score), 1, (0, 0, 0))
       screen.blit(text, (5, 10))
       pygame.display.update()


main()
```

## 最後に
　今回作成したゲームは、ひとつのオブジェクトの持つメソッドが多く、少々混乱してしまいました。前回作ったPingPongゲームよりも複雑な処理が多くなっているので、徐々にステップアップできているかなと思います。今後も徐々に難しい題材に挑戦していきたいと思います。