テトリス風落ち物パズルを作る part03 キー入力・落下処理

[前回](https://qiita.com/comet725/items/a4c8a544e49cfb29d5f4)ではテトリミノを配置した。
今回はキー入力とミノの落下処理を追加する。

# 座標管理
x, y の位置管理を一つのクラスにまとめておく

```py:pos.py
from dataclasses import dataclass
@dataclass(frozen=True)
class Pos(object):
    x: int = 0
    y: int = 0
    def __hash__(self):
        return hash((self.x, self.y))

    def __eq__(self, other):
        if isinstance(other, Pos):
            return self.x == other.x and self.y == other.y
        return False
    def __iadd__(self, other):
        return NotImplemented
    def __add__(self, other):
        match other:
            case Pos():
                return Pos(self.x+other.x, self.y+other.y)
            case (x, y):
                return Pos(self.x+x, self.y+y)
            case _:
                kls = other.__class__.__name__
                raise NotImplementedError(
                    f'Addtion between Pos and {kls} is not supported')
    def plus(self,*args):
        match args:
            case Pos(),:
                other = args[0]
                return Pos(self.x+other.x, self.y+other.y)
            case x, y, *_:
                return Pos(self.x+x, self.y+y)
            case tuple() as p, *_:
                x, y, *_ = p 
                return Pos(self.x+x, self.y+y)
def main():
    # Posオブジェクト同士の加算演算子を使用して新しいPosオブジェクトを作成
    print(Pos(1,1)+Pos(2,3))
    print(Pos(1,1)+(2,3))
    # Posオブジェクトに同じPosオブジェクトを加えるメソッドを使用
    print(Pos(1,1).plus(Pos(1,1)))
    # Posオブジェクトにタプル形式の値を加えるメソッドを使用
    print(Pos(1,1).plus((1,1)))

if __name__ == '__main__':
    main()
```

実行してみた結果
```
Pos(x=3, y=4)
Pos(x=3, y=4)
Pos(x=2, y=2)
Pos(x=2, y=2)
```
計算をまとめてできる

# キー入力
ワールドルールの以下のシステムも適応している(ため押ししたら連続で動くしくみ: [参考](https://nestetrisjp.github.io/das-introduction/))
- DAS: Delayer Auto Shift
- ARR: Auto Repeat Rate


```py:key.py
import pygame

DAS_FRAME = 20
ARR_FRAME = 2
class Key:
    # 各キーの押下状態を保持
    is_pushed = {
            "z": False,
            "x": False,
            "DOWN": False,
            "LEFT": False,
            "RIGHT": False
        }
        
    # 各キーが押された時点のフレーム数を保持
    key_timestamps = {
            "z": 0,
            "x": 0,
            "DOWN": 0,
            "LEFT": 0,
            "RIGHT": 0
        }
    is_moved : bool = False
    is_spined: bool = False
    def process_key_input(self):
        keys = pygame.key.get_pressed()
        self.flags = {
            "is_left_rot": False,
            "is_right_rot": False,
            "is_left_move": False,
            "is_right_move": False,
            "is_soft_drop": False,
        }       
        for key, state in self.is_pushed.items():
            if keys[getattr(pygame, f'K_{key}')] and not state:
                # キーが押された瞬間
                self.is_pushed[key] = True
                self.key_timestamps[key] += 1
            elif not keys[getattr(pygame, f'K_{key}')] and state:
                # キーが離された瞬間
                self.is_pushed[key] = False
                self.key_timestamps[key] = 0
            elif state:
                self.handle_key_press(key) 
                self.key_timestamps[key] += 1
    def handle_key_press(self, key):
        
        cnt = self.key_timestamps[key]
        # DAS: Delayer Auto Shift, ARR: Auto Repeat Rate
        das = (cnt >= DAS_FRAME and cnt % ARR_FRAME == 0)
        
        match key:
            case "z":
                self.flags["is_left_rot"] = cnt == 1
            case "x":
                self.flags["is_right_rot"] = cnt == 1
            case "DOWN":
                self.flags["is_soft_drop"] = cnt == 1 or das
            case "LEFT":
                self.flags["is_left_move"] = cnt == 1 or das
            case "RIGHT":
                self.flags["is_right_move"] = cnt == 1 or das
```

# プレイヤーの操作情報を追加

プレイヤーが操作しているテトリミノの情報を管理するクラスを`mino.py`に追加する

```py:mino.py
from dataclasses import dataclass
from enum import Enum, auto

import pytmx

from pos import Pos

# tmx ファイルからミノを構成するブロックの配置を定義する
tmx_data = pytmx.TiledMap('mino_block.tmx')
def get_mino_data(t, size=4):
    
    shapes = [[] for _ in range(4)]
    tile = 0
    for r in range(4):
        for y in range(size):
            for x in range(size):
                gid = tmx_data.get_tile_gid(size*t+x, size*(r%4)+y, 0)
                if gid != 0:
                    tile = gid
                    shapes[r].append((x,y))        
    return tile, shapes

# ミノの形を定義する列挙型
class MinoType(Enum):
    Z = 'Z', auto(), *get_mino_data(0)
    S = 'S', auto(), *get_mino_data(1)
    J = 'J', auto(), *get_mino_data(2)
    L = 'L', auto(), *get_mino_data(3)
    T = 'T', auto(), *get_mino_data(4)
    O = 'O', auto(), *get_mino_data(5)
    I = 'I', auto(), *get_mino_data(6)
    def __init__(self, name, value, tile, shape):
        self._name = name
        self._value_ = value
        self.tile = tile
        self.shape = tuple(tuple(s) for s in shape)
        self.shape_pos = tuple(tuple(Pos(*p) for p in s) for s in shape)
    
    @classmethod
    def values(cls):
        # Order dictionary -> list
        return list(cls.__members__.values())

# 追加
@dataclass
class Mino:
    m : MinoType
    p : Pos
    r : int
    def move(self,pos:Pos,r):
        self.r = (r+self.r) % 4
        self.p += pos
    def get_shape(self):
        for p in self.m.shape_pos[self.r%4]:
            yield p + self.p
    def get_moved_mino(self,pos:Pos,r:int):
        for p in self.m.shape_pos[(100+r+self.r)%4]:
            yield p + self.p + pos

def main():
    for mino in MinoType.values():
        print(mino.name, mino.value, mino.tile)
        print(mino.shape)
        print(mino.shape_pos)
        
if __name__ == '__main__':
    main()

```

# 落下処理

追加したクラスを使って処理を実装する

```py:main.py
from random import choice
import pygame
from pytmx import util_pygame as tmx_util

from mino import MinoType, Mino
from key import Key
from pos import Pos

TILE_SIZE = 32

MATRIX_SIZE = (20,10)
MATRIX_POS  = (11,1)
WAIT = 60
GRAVITY = 60
SOFT_DROP_SPEED = 20

start_pos = Pos(4,-2)

class Game:
    
    # 番兵用に連想配列でラクする
    matrix = {
        i:{
            j:0 for j in range(-2,12)
        } for i in range(-20,21)
    }
    
    key = Key()
    
    mino : Mino
    
    g_cnt = 0
    
    def __init__(self):
        pygame.init()
        self.screen = pygame.display.set_mode((1024, 768))

    def draw_tile(self, tmx, x, y, t):
        tile_image = tmx.get_tile_image_by_gid(t)
        if tile_image:
            self.screen.blit(tile_image, (x * TILE_SIZE, y * TILE_SIZE))

    def mino_setup(self):
        t = choice(MinoType.values())
        self.mino = Mino(t,start_pos,0)

    def can_mino_move(self,mino:Mino,pos:Pos,r:int):
        for p in mino.get_moved_mino(pos,r):
            if self.matrix[p.y][p.x] != 0:
                return False
        return True
    
    def move_and_rotate_mino(self):
        vpos = Pos()
        vr = 0
        # 回転と移動のフラグをチェックして状態を更新
        if self.key.flags["is_left_rot"]:
            vr += 3
        elif self.key.flags["is_right_rot"]:
            vr += 1
        elif self.key.flags["is_left_move"]:
            vpos += Pos(-1, 0)
        elif self.key.flags["is_right_move"]:
            vpos += Pos(1, 0)
        # ソフトドロップと重力の処理
        if self.key.flags["is_soft_drop"]:
            self.g_cnt += SOFT_DROP_SPEED
        elif self.g_cnt < GRAVITY:
            self.g_cnt += 1
            
        # 重力によるミノの移動
        while self.g_cnt >= GRAVITY:
            if self.can_mino_move(self.mino, Pos(0, 1), 0):
                self.mino.p += Pos(0, 1)
                self.g_cnt -= GRAVITY
                self.wait_count = WAIT
            else:
                self.g_cnt = 0
                break
        # 可能であればミノを移動または回転
        if vpos.x == 0 and vpos.y == 0 and vr == 0:
            pass
        elif self.can_mino_move(self.mino, vpos, vr):
            self.mino.move(vpos, vr)

    def run(self):
        clock = pygame.time.Clock()
        running = True
        
        tmx_data = tmx_util.load_pygame('field.tmx')
        mino_block = tmx_util.load_pygame('mino_block.tmx')
        
        self.mino_setup()
        
        # 番兵を設置
        for i in range(-20,21):
            for j in range(-2,12):
                if j < 0 or j >= 10:
                    self.matrix[i][j] = 1
                else:
                    self.matrix[i][j] = 0
        for i in range(-2,12):
            self.matrix[20][i] = 1
        
        while running:
            self.key.process_key_input() # キー入力の処理
            self.move_and_rotate_mino()
            
            
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    running = False

            self.screen.fill((0, 0, 0))

            for layer in tmx_data.layers:
                if layer.name == "mainfield":
                    for x, y, gid in layer.iter_data():
                        self.draw_tile(tmx_data, x, y, gid)
            
            x, y = MATRIX_POS
            for i in range(-1, MATRIX_SIZE[0]):
                for j in range(MATRIX_SIZE[1]):
                    self.draw_tile(mino_block, x+j, y+i, self.matrix[i][j])
            for p in self.mino.get_shape():
                self.draw_tile(mino_block, x+p.x, y+p.y, self.mino.m.tile)

            pygame.display.flip()
            clock.tick(60)

        pygame.quit()

if __name__ == "__main__":
    game = Game()
    game.run()

```

# 動作チェック
実行してみた結果を録画してみた

<iframe width="560" height="315" src="https://www.youtube.com/embed/W5CacW-ptcc?si=qX-bMJlLR1IKEkbB" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

# まとめ

駆け足になってしまったが以下を追加した
- 座標を管理するクラス
- キー入力を管理するクラス
- 操作しているミノの情報を管理するクラス
- 上記のクラスを使ってミノを落下させたり、操作する処理

次回: [テトリス風落ち物パズルを作る part04 ミノの設置とライン消去 #Python - Qiita](https://qiita.com/comet725/items/6161f05b312ff2912495)