テトリス風落ち物パズルを作る part05 SRSとロックダウン(ワールドルールの適応)


[前回](https://qiita.com/comet725/items/6161f05b312ff2912495)はキー入力とミノの落下処理を作った。
今回はワールドルールの一つであるSRSとロックダウンを実装していく。

SRS: Super Rotation System
- ミノが壁とか床に接している状態でも回転できるように位置の補正を行う処理
- Tspin Tripleができるのはこのシステムのおかげ
- どう補正されるの解説とかは色々複雑なので以下のページ等を参照してほしい
  - [Tetrisちゃんねる](https://tetrisch.github.io/main/srs.html)

ロックダウン
- 今まで設置判定適当だったけどある程度ワールドルールに準拠したい
- 地面に設置した判定から、一定時間動かす余地を与えてから設置(これは今まで実装していた)
- 地面に設置した状態回転や移動するとミノの固定までの時間が一旦リセットされる(今回これを追加)[^lockdown]
- 何回もやれてしまうと無限ループが起きるためリセットは大体15回までに制限する

[^lockdown]: 今までこのタイマーリセットの仕様をロックダウンと思っていたのは内緒

# SRSの補正を考える
使用して要は「その位置でミノが回転できないなら、ミノの位置を補正した状態なら回転可能か順番にチェックする」ということ。
そして、その補正の仕方が「それぞれの向きから右、左回転するとき」、「Iミノかそれ以外のとき」で違うという話。

だから全パターンをjsonファイルなどで網羅すればヨシ！


```json:srs_pattern.json
{
    "SRScommon":{
        "offset_ptn_lst":[
            [[ 0, 0],[ 0, 0],[ 0, 0],[ 0, 0],[ 0, 0]],
            [[ 0, 0],[-1, 0],[-1,-1],[ 0, 2],[-1, 2]],
            [[ 0, 0],[ 1, 0],[ 1,-1],[ 0, 2],[ 1, 2]],
            [[ 0, 0],[ 1, 0],[ 1, 1],[ 0,-2],[ 1,-2]],
            [[ 0, 0],[-1, 0],[-1, 1],[ 0,-2],[-1,-2]]
        ],
        "ptn_select_mat":[
            [ 0, 1, 0, 2],
            [ 3, 0, 3, 0],
            [ 0, 1, 0, 2],
            [ 4, 0, 4, 0]
        ]
    },
    "SRSminoI":{
        "offset_ptn_lst":[
            [[ 0, 0],[ 0, 0],[ 0, 0],[ 0, 0],[ 0, 0]],
            [[ 0, 0],[-1, 0],[ 2, 0],[-1,-2],[ 2, 1]],
            [[ 0, 0],[-2, 0],[ 1, 0],[-2, 1],[ 1,-2]],
            [[ 0, 0],[ 2, 0],[-1, 0],[ 2,-1],[-1, 2]],
            [[ 0, 0],[ 1, 0],[-1, 0],[ 1, 2],[-2,-1]]
        ],
        "ptn_select_mat":[
            [ 0, 2, 0, 1],
            [ 3, 0, 1, 0],
            [ 0, 4, 0, 3],
            [ 4, 0, 2, 0]
        ]
    }
}
```

これをminoクラスに読ませてタプルやPosに変換する。

```py:mino.pyの更新(一部)
import json

srs_patterns = None
with open('srs_pattern.json', 'r') as f:
    srs_patterns = json.load(f)

# valuesメソッドを持つEnum (いろんなクラスで使うのでスーパークラス化した)
class VEnum(Enum):
    @classmethod
    def values(cls):
        # Order dictionary -> list
        return list(cls.__members__.values())

class SRS(VEnum):
    common = 'SRScommon', auto()
    minoI  = 'SRSminoI' , auto()
    def __init__(self, name, value):
        self._name = name
        self._value_ = value
        ptn = srs_patterns[name]
        self.offset_ptn_lst = tuple(tuple(Pos(*a) for a in A) for A in ptn["offset_ptn_lst"])
        self.ptn_select_mat = tuple(tuple(A) for A in ptn["ptn_select_mat"])
    def get_rotate_offsets(self, rot1, rot2):
        ptn_select = self.ptn_select_mat[rot1][rot2]
        for p in self.offset_ptn_lst[ptn_select]:
            yield p

class MinoType(VEnum):
    Z = 'Z', auto(), *get_mino_data(0), SRS.common
    S = 'S', auto(), *get_mino_data(1), SRS.common
    J = 'J', auto(), *get_mino_data(2), SRS.common
    L = 'L', auto(), *get_mino_data(3), SRS.common
    T = 'T', auto(), *get_mino_data(4), SRS.common
    O = 'O', auto(), *get_mino_data(5), SRS.common
    I = 'I', auto(), *get_mino_data(6), SRS.minoI
    def __init__(self, name, value, tile, shape, srs):
        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)
        self.srs = srs
```

テストコードでは表示を見やすいように`pprint`を使用する
- [pprint --- データの整形表示 — Python 3.12.4 ドキュメント](https://docs.python.org/ja/3/library/pprint.html)

```py:mino.py　更新(その2)
from pprint import pprint

# ---中略---

def main():
    for mino in MinoType.values():
        print(mino.name, mino.value, mino.tile)
    for mino in OtherTiles.values(): 
        print(mino.name, mino.value, mino.tile)
    # 追加
    for srs in SRS.values():
        print(srs)
        print(srs._name)
        print(srs.value)
        pprint(srs.offset_ptn_lst)
        pprint(srs.ptn_select_mat)
```
実行結果、すべてtupleとPosに変換できていそう

```mino.py 実行結果
SRS.common
SRScommon
1
((Pos(x=0, y=0), Pos(x=0, y=0), Pos(x=0, y=0), Pos(x=0, y=0), Pos(x=0, y=0)),
 (Pos(x=0, y=0),
  Pos(x=-1, y=0),
  Pos(x=-1, y=-1),
  Pos(x=0, y=2),
  Pos(x=-1, y=2)),
 (Pos(x=0, y=0), Pos(x=1, y=0), Pos(x=1, y=-1), Pos(x=0, y=2), Pos(x=1, y=2)),
 (Pos(x=0, y=0), Pos(x=1, y=0), Pos(x=1, y=1), Pos(x=0, y=-2), Pos(x=1, y=-2)),
 (Pos(x=0, y=0),
  Pos(x=-1, y=0),
  Pos(x=-1, y=1),
  Pos(x=0, y=-2),
  Pos(x=-1, y=-2)))
((0, 1, 0, 2), (3, 0, 3, 0), (0, 1, 0, 2), (4, 0, 4, 0))
SRS.minoI
SRSminoI
2
((Pos(x=0, y=0), Pos(x=0, y=0), Pos(x=0, y=0), Pos(x=0, y=0), Pos(x=0, y=0)),
 (Pos(x=0, y=0), Pos(x=-1, y=0), Pos(x=2, y=0), Pos(x=-1, y=-2), Pos(x=2, y=1)),
 (Pos(x=0, y=0), Pos(x=-2, y=0), Pos(x=1, y=0), Pos(x=-2, y=1), Pos(x=1, y=-2)),
 (Pos(x=0, y=0), Pos(x=2, y=0), Pos(x=-1, y=0), Pos(x=2, y=-1), Pos(x=-1, y=2)),
 (Pos(x=0, y=0), Pos(x=1, y=0), Pos(x=-1, y=0), Pos(x=1, y=2), Pos(x=-2, y=-1)))
((0, 2, 0, 1), (3, 0, 1, 0), (0, 4, 0, 3), (4, 0, 2, 0))
```


# 回転処理にSRSを組み込む

追加したSRSのデータを回転・移動処理のメソッドの中で使用するようにする。

```py: main.pyの更新
def move_and_rotate_mino(self):
    # ---中略---
    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)
    elif vr != 0: # 追加
        # SRSの実行
        rot1 = self.mino.r % 4
        rot2 = (self.mino.r + vr) % 4
        for p in self.mino.m.srs.get_rotate_offsets(rot1, rot2):
            if self.can_mino_move(self.mino, vpos + p, vr):
                self.mino.move(vpos + p, vr)
                break
```


# ロックダウンの追加

ロックダウンの実装は単純で設置後に回るカウンターのリセット処理をメソッドに入れて

```py:main.py lock_downメソッドの追加

class Game:
    # ---中略---
    lockdown_cnt = 0 # 追加
    # ---中略---
    def lock_down(self):
        if self.wait_count == WAIT:
            return
        if self.lockdown_cnt < 15:
            self.lockdown_cnt += 1
            self.g_cnt = 0
            self.wait_count = WAIT

```

移動処理で左右に動かしたり回転できたりした場合にそのメソッドを呼ぶ
```py:main.py lock_downメソッドの呼び出し
def move_and_rotate_mino(self):
    # 可能であればミノを移動または回転
    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)

        # 追加
        if vpos.y > 0:
            self.lockdown_cnt = 0
        else:
            self.lock_down()
    elif vr != 0:
        # SRSの実行
        rot1 = self.mino.r % 4
        rot2 = (self.mino.r + vr) % 4
        for p in self.mino.m.srs.get_rotate_offsets(rot1, rot2):
            if self.can_mino_move(self.mino, vpos + p, vr):
                self.mino.move(vpos + p, vr)
                self.lock_down() # 追加
                break
```


# 動作確認

動作確認用にSRSの動きを確認するためミノの出現順を固定させたり、地形を追加したりのデバッグコードを追加した。

```py:main.py デバッグコード

class Game:
    # ---中略---
    # デバッグコード
    is_debug = True
    debug_mino = [
        MinoType.T,
        MinoType.T,
        MinoType.T,
        MinoType.J,
        MinoType.L,
        MinoType.Z,
        MinoType.L,
        MinoType.J,
        MinoType.S,
        MinoType.O,
        MinoType.O,
    ]
    
    put_mino_cnt = 0

    def for_debug(self):
        # DT砲とTDアタックを組み合わせたような地形を作る
        DTD = [
            [0,0,7,7,7,7,7,7,7,7,],
            [0,0,0,7,7,7,7,7,7,7,],
            [7,7,0,7,7,7,7,7,7,7,],
            [7,0,0,7,7,7,7,7,7,7,],
            [7,0,0,0,7,7,7,7,7,7,],
            [7,7,0,7,7,7,7,7,7,7,],
            [7,7,0,7,7,7,7,7,7,7,],
            [7,0,7,7,7,7,7,7,7,7,],
        ]
        # フィールドに書き込む
        for i, c in enumerate(DTD,start=12):
            for j, b in enumerate(c):
                self.matrix[i][j] = b

    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

        # デバッグコード
        if self.is_debug :self.for_debug()
        
        while running:
            # 省略
```



動作確認用の動画
<iframe width="560" height="315" src="https://www.youtube.com/embed/SVdvSGeIvNE?si=U2f8swQF2P7LQr-Q" 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>

SRSやミノを動かしたときの設置の猶予時間等が反映されていることが確認できる。

# まとめ

ワールドルール準拠にしたSRSとロックダウン(設置判定)の適応した

次回：[テトリス風落ち物パズルを作る part06 ゲームオーバーの判定 #Python - Qiita](https://qiita.com/comet725/items/d6d66a6005ab0c25287f)


# 付録

今回更新したソースコードコード全体を以下に示す

<details><summary>main.py</summary>

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

from mino import MinoType, Mino, OtherTiles
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)
    }
    
    is_debug = True
    debug_mino = [
        MinoType.T,
        MinoType.T,
        MinoType.T,
        MinoType.J,
        MinoType.L,
        MinoType.Z,
        MinoType.L,
        MinoType.J,
        MinoType.S,
        MinoType.O,
        MinoType.O,
    ]
    
    key = Key()
    
    mino : Mino
    put_mino_cnt = 0
    lockdown_cnt = 0
    
    g_cnt = 0
    wait_count = WAIT
    is_draw_mino = True
    
    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 for_debug(self):
        DTD = [
            [0,0,7,7,7,7,7,7,7,7,],
            [0,0,0,7,7,7,7,7,7,7,],
            [7,7,0,7,7,7,7,7,7,7,],
            [7,0,0,7,7,7,7,7,7,7,],
            [7,0,0,0,7,7,7,7,7,7,],
            [7,7,0,7,7,7,7,7,7,7,],
            [7,7,0,7,7,7,7,7,7,7,],
            [7,0,7,7,7,7,7,7,7,7,],
        ]
        for i, c in enumerate(DTD,start=12):
            for j, b in enumerate(c):
                self.matrix[i][j] = b

    def mino_setup(self):
        self.wait_count = WAIT
        self.is_draw_mino = True
        
        t = choice(MinoType.values())
        if self.is_debug and self.put_mino_cnt < len(self.debug_mino):
            t = self.debug_mino[self.put_mino_cnt]
        self.put_mino_cnt+=1
        self.mino = Mino(t,start_pos,0)
        if not self.can_mino_move(self.mino,Pos(0,0),0):
            for i in range(3):
                if self.can_mino_move(self.mino,Pos(0,-i),0):
                    self.mino.move(Pos(0,-i))
                    break
        
    def put_mino(self):
        self.is_draw_mino = False
        m = self.mino
        for p in m.get_shape():
            self.matrix[p.y][p.x] = m.m.tile
    def line_check(self):
        for y in range(19, -2, -1):
            n = 0
            for x in range(10):
                if self.matrix[y][x]!=0:
                    n += 1
            if n != 10:
                continue
            else:
                for x in range(10):
                    self.matrix[y][x] = OtherTiles.Vanish.tile
    def clear_line(self):
        for y in range(19,-3,-1):
            while self.matrix[y][0] == OtherTiles.Vanish.tile:
                self.wait_count = WAIT // 2 - 2
                for i in range(y, -20, -1):
                    for x in range(10):
                        t = self.matrix[i - 1][x]
                        self.matrix[i][x] = t
                for x in range(10):
                    self.matrix[-20][x] = 0
    def wait(self) -> None:
        if self.wait_count == 0:
            self.mino_setup()
        else:
            self.wait_count -=1
        if self.wait_count == WAIT // 2  - 1:
            self.put_mino()
            self.line_check()
        if self.wait_count == 1:
            self.clear_line()

    def lock_down(self):
        if self.wait_count == WAIT:
            return
        if self.lockdown_cnt < 15:
            self.lockdown_cnt += 1
            self.g_cnt = 0
            self.wait_count = WAIT

    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):
        # 待機時間が設定の半分以下の場合は待機
        if self.wait_count <= WAIT // 2:
            self.wait()
            return
        vpos = Pos()
        vr = 0
        # 床に着地しているかのチェック
        is_floor_landed = not self.can_mino_move(self.mino, Pos(0, 1), 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 not is_floor_landed and self.key.flags["is_soft_drop"]:
            self.g_cnt += SOFT_DROP_SPEED
        elif self.g_cnt < GRAVITY:
            self.g_cnt += 1
            
        if is_floor_landed:
            self.wait_count -= 1
        # 重力によるミノの移動
        while self.g_cnt >= GRAVITY:
            if not is_floor_landed:
                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)
            if vpos.y > 0:
                self.lockdown_cnt = 0
            else:
                self.lock_down()
        elif vr != 0:
            # SRSの実行
            rot1 = self.mino.r % 4
            rot2 = (self.mino.r + vr) % 4
            for p in self.mino.m.srs.get_rotate_offsets(rot1, rot2):
                if self.can_mino_move(self.mino, vpos + p, vr):
                    self.mino.move(vpos + p, vr)
                    self.lock_down()
                    break

    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
            
        if self.is_debug :self.for_debug()
        
        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])
            if self.is_draw_mino:
                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()

```

</details>


<details><summary>mino.py</summary>

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

import pytmx


from pos import Pos

# tmx ファイルからミノを構成するブロックの配置を定義する
tmx_data = pytmx.TiledMap('mino_block.tmx')
srs_patterns = None
with open('srs_pattern.json', 'r') as f:
    srs_patterns = json.load(f)

    

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

# valuesメソッドを持つEnum (いろんなクラスで使うのでスーパークラス化した)
class VEnum(Enum):
    @classmethod
    def values(cls):
        # Order dictionary -> list
        return list(cls.__members__.values())

class SRS(VEnum):
    common = 'SRScommon', auto()
    minoI  = 'SRSminoI' , auto()
    def __init__(self, name, value):
        self._name = name
        self._value_ = value
        ptn = srs_patterns[name]
        self.offset_ptn_lst = tuple(tuple(Pos(*a) for a in A) for A in ptn["offset_ptn_lst"])
        self.ptn_select_mat = tuple(tuple(A) for A in ptn["ptn_select_mat"])
    def get_rotate_offsets(self, rot1, rot2):
        ptn_select = self.ptn_select_mat[rot1][rot2]
        for p in self.offset_ptn_lst[ptn_select]:
            yield p

class OtherTiles(VEnum):
    Vanish = "Vanish", -1, Pos(28,15)
    def __init__(self, name, value, p):
        self._name = name
        self._value_ = value
        self.tile = tmx_data.get_tile_gid(p.x,p.y,0)

# ミノの形を定義する列挙型
class MinoType(VEnum):
    Z = 'Z', auto(), *get_mino_data(0), SRS.common
    S = 'S', auto(), *get_mino_data(1), SRS.common
    J = 'J', auto(), *get_mino_data(2), SRS.common
    L = 'L', auto(), *get_mino_data(3), SRS.common
    T = 'T', auto(), *get_mino_data(4), SRS.common
    O = 'O', auto(), *get_mino_data(5), SRS.common
    I = 'I', auto(), *get_mino_data(6), SRS.minoI
    def __init__(self, name, value, tile, shape, srs):
        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)
        self.srs = srs

@dataclass
class Mino:
    m : MinoType
    p : Pos
    r : int
    def move(self,pos:Pos,r:int=0):
        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=0):
        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)
    for mino in OtherTiles.values(): 
        print(mino.name, mino.value, mino.tile)
    for srs in SRS.values():
        print(srs)
        print(srs._name)
        print(srs.value)
        pprint(srs.offset_ptn_lst)
        pprint(srs.ptn_select_mat)
        
if __name__ == '__main__':
    main()

```

</details>