テトリス風落ち物パズルを作る part10 ホールドの実装


[前回](https://qiita.com/comet725/items/e803fc240aa4cc9893c3)はネクストミノの表示を実装した

今回はホールドの実装を行う
~~これで左枠の削除しないで済みます~~

ホールド機能とは：テトリミノを1個だけ保存する機能

# 変数の追加

ホールドは連続して使えないので、フラグ用の変数とホールドしたミノを格納する変数を追加する。
ミノを置いたときにホールドを使えるようにフラグを初期化しておく。

```diff_python
start_pos = Pos(3,-2)
+hold_pos  = Pos(4, 3)

next_pos_lst = tuple(Pos(24,3+3*i) for i in range(6))

class Game:
    # ---中略---
+   hold_mino = None
+   is_hold_used = False
    # ---中略---
    def mino_setup(self):
        self.wait_count = WAIT
        self.lockdown_cnt = 0
        self.is_draw_mino = True
+       self.is_hold_used = False
        
        t = self.pop_bag()
        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
            else:
                self.gameover = True
```

# ホールド処理の関数

holdを実行する関数を実装する
この関数ではまだ何もホールドしていないときは
ホールドに操作中のミノを格納した後そのまま次のミノを出現させる。
ホールド2回目以降ではホールドしたミノと操作中のミノを交換して、
ミノの位置を初期地点に移動させる。

```diff_python
    def hold(self) -> None:
        if self.hold_mino is None:
            self.hold_mino = self.mino.m
            self.mino_setup()
        else:
            self.g_cnt = 0
            self.wait_count = WAIT
            self.mino.r = 0
            self.hold_mino, t = self.mino.m, self.hold_mino
            self.mino = Mino(t,start_pos,0)
        self.is_hold_used = True
```


# キー入力追加

スペースキー入力でホールド発動のフラグを立てる。
- DAS,ARRは実装しない

```diff_python:key.py
import pygame

DAS_FRAME = 20
ARR_FRAME = 2
class Key:
    # 各キーの押下状態を保持
    is_pushed = {
            "z": False,
            "x": False,
+           "SPACE": False,
            "UP": False,
            "DOWN": False,
            "LEFT": False,
            "RIGHT": False
        }
        
    # 各キーが押された時点のフレーム数を保持
    key_timestamps = {
            "z": 0,
            "x": 0,
+           "SPACE": 0,
            "UP": 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,
            "is_hard_drop": False,
+           "is_hold": 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 "SPACE":
+               self.flags["is_hold"] = cnt == 1
            case "UP":
                self.flags["is_hard_drop"] = 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
```

追加したキー入力があったら、ホールド関数を実行する。ホールド実行したらそのフレームでやることはないのでreturnしておく

```diff_python
    def move_and_rotate_mino(self):
        # 待機時間が設定の半分以下の場合は待機
        if self.wait_count <= WAIT // 2:
            self.wait()
            return
        
+       # ホールドの処理
+       if self.key.flags["is_hold"] and not self.is_hold_used:
+           self.hold()
+           return
```


# ホールドしたミノの描画

適当な箇所に描画処理を追加する

```diff_python
class Game:
    # ---中略---
    def run(self):
        # ---中略---
        while running:
            # ---中略---
            
            # NEXT
            for i, (t, p) in enumerate(zip(self.nexts,next_pos_lst)):
                for pt in t.shape_pos[0]:
                    np = pt + p
                    self.draw_tile(mino_block, np.x, np.y, t.tile)
            
+          # HOLD
+           if self.hold_mino is not None:
+               for p in self.hold_mino.shape_pos[0]:
+                   hp = p + hold_pos
+                   self.draw_tile(mino_block, hp.x, hp.y, self.hold_mino.tile)

            # 21段目チラ見せ
            self.screen.fill((0, 0, 0), 
                (10*TILE_SIZE,0,12*TILE_SIZE,TILE_SIZE*2//3)
            )
            
            pygame.display.flip()
            clock.tick(60)
```

# 動作確認

実行してみた結果を録画してみた

<iframe width="560" height="315" src="https://www.youtube.com/embed/DGj_-kn-Zpg?si=MTe5BVAn1ohrUTby" 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>

動画では適当に自分が使えるテンプレ(BT砲、山岳積み2️号、罰金砲[^bakkin])を打っているが
その際に何度かホールドを実行しているのが見えるだろうか。

[^bakkin]: Zスピンが上手くいかなくて失敗しているのは内緒

# まとめ

今回はホールド機能の実装を行った。
フラグ管理用の変数等を追加しホールド関数を作成、
ホールド関数ではまだ何も入っていないときには単純に操作中のミノを格納して
次のミノを出現させる。
2回目以降ではホールドしたミノと操作中のミノを交換して。
初期地点に戻す処理を行う。

スペースキー入力でホールドの実行フラグが立つようにし、
ホールド関数を実行。
そのときに格納されたミノは左の枠に表示した。

ここまで来るとコンシューマーゲームのテトリスと同じようなことができるようになってきた。

次回:[テトリス風落ち物パズルを作る part11 ゴースト(落下予測地点の表示) #Python - Qiita](https://qiita.com/comet725/items/cdfae4ad236da509425d)

# 付録

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

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

from mino import Mino7Bag, RandomBag, 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(3,-2)
hold_pos  = Pos(4, 3)

next_pos_lst = tuple(Pos(24,3+3*i) for i in range(6))

class Game:
    
    # 番兵用に連想配列でラクする
    matrix = {
        i:{
            j:0 for j in range(-1,11)
        } for i in range(-20,21)
    }
    
    is_debug = False
    
    key = Key()
    
    mino : Mino
    put_mino_cnt = 0
    lockdown_cnt = 0
    
    gameover = False
    
    bag = Mino7Bag()
    nexts = deque()
    
    hold_mino = None
    is_hold_used = False
       
    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):
        pass
    def pop_bag(self):
        p = self.nexts.popleft()
        self.nexts.append(self.bag.get_mino())
        return p
    
    def mino_setup(self):
        self.wait_count = WAIT
        self.lockdown_cnt = 0
        self.is_draw_mino = True
        self.is_hold_used = False
        
        t = self.pop_bag()
        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
            else:
                self.gameover = True
        
    def put_mino(self):
        self.is_draw_mino = False
        m = self.mino
        flg = True
        for p in m.get_shape():
            self.matrix[p.y][p.x] = m.m.tile
            if p.y >= 0:
                flg = False
        if flg:
            self.gameover = True
    def line_check(self):
        flg = False
        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:
                flg = True
                for x in range(10):
                    self.matrix[y][x] = OtherTiles.Vanish.tile
        return flg
    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 hold(self) -> None:
        if self.hold_mino is None:
            self.hold_mino = self.mino.m
            self.mino_setup()
        else:
            self.g_cnt = 0
            self.wait_count = WAIT
            self.mino.r = 0
            self.hold_mino, t = self.mino.m, self.hold_mino
            self.mino = Mino(t,start_pos,0)
        self.is_hold_used = True
    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 p.x < 0 or p.x > 10 or 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
        
        # ホールドの処理
        if self.key.flags["is_hold"] and not self.is_hold_used:
            self.hold()
            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 self.key.flags["is_hard_drop"]:
            while self.can_mino_move(self.mino, Pos(0, 1), 0):
                self.mino.move(Pos(0, 1))
            self.put_mino()
            if self.line_check():
                self.wait_count = WAIT // 2 - 2
            else:
                self.wait_count = 2    
        
        # 可能であればミノを移動または回転
        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')
        
        for _ in range(len(next_pos_lst)):
           self.nexts.append(self.bag.get_mino())
        
        self.mino_setup()
        gameover_cnt = 0
        gameover_cnt_sub = 0
        
        # 番兵を設置
        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:
            if not self.gameover:
                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)
            
            if self.gameover:                
                # ゲームオーバーの演出、下から灰色になっていく
                for i in range(20,19-gameover_cnt,-1):
                    for j in range(10):
                        if self.matrix[i][j] != 0:
                            self.matrix[i][j] = 8
            
                if gameover_cnt_sub == 2:
                    gameover_cnt = min(22, gameover_cnt+1)
                    gameover_cnt_sub = 0
                else: gameover_cnt_sub += 1 
            
            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  not self.gameover:                
                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)

            # NEXT
            for i, (t, p) in enumerate(zip(self.nexts,next_pos_lst)):
                for pt in t.shape_pos[0]:
                    np = pt + p
                    self.draw_tile(mino_block, np.x, np.y, t.tile)
            
            # HOLD
            if self.hold_mino is not None:
                for p in self.hold_mino.shape_pos[0]:
                    hp = p + hold_pos
                    self.draw_tile(mino_block, hp.x, hp.y, self.hold_mino.tile)

            # 21段目チラ見せ
            self.screen.fill((0, 0, 0), 
                (10*TILE_SIZE,0,12*TILE_SIZE,TILE_SIZE*2//3)
            )
            
            pygame.display.flip()
            clock.tick(60)

        pygame.quit()

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

```

</details>

<details><summary>key.py</summary>
import pygame

DAS_FRAME = 20
ARR_FRAME = 2
class Key:
    # 各キーの押下状態を保持
    is_pushed = {
            "z": False,
            "x": False,
            "SPACE": False,
            "UP": False,
            "DOWN": False,
            "LEFT": False,
            "RIGHT": False
        }
        
    # 各キーが押された時点のフレーム数を保持
    key_timestamps = {
            "z": 0,
            "x": 0,
            "SPACE": 0,
            "UP": 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,
            "is_hard_drop": False,
            "is_hold": 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 "SPACE":
                self.flags["is_hold"] = cnt == 1
            case "UP":
                self.flags["is_hard_drop"] = 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

</details>