[Haskell] 2048（ゲーム）を作る

# はじめに
[Haskell Advent Calendar 2024](https://qiita.com/advent-calendar/2024/haskell) の 12 日目の投稿です。
Haskellのアドベントカレンダーが開催されていることを知り、何か書いて参加したいな〜と思っていたのですが、なかなかいい感じのネタが見つからず...

ふとゲームを作ればいいということに気づいたので、ちょっと前に流行っていたゲーム[2048](https://play2048.co/)を作成してみることにしました！

まだまだHaskellに関しては未熟者ですが、一応完成したので簡単なコードの説明を書いておきます。

ChatGPTと協力しながら書いているので、変な書き方をしているかもしれないです。もっといい書き方がありましたら指摘していただけるとありがたいです！

# スクリーンショット
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3289319/a0701948-65e8-f982-8930-7fc8bb19e9eb.png)


# 目標
ターミナルで動く[2048](https://play2048.co/)を作成する。

# ゲームのルール
- グリッド構成
4×4のグリッド上に数字が書かれたタイルを配置します。ゲーム開始時にランダムに2つのタイルが配置されます。

- 操作方法
W、A、S、Dキーを使用してタイルを移動させます。すべてのタイルが指定した方向にスライドします。

- タイルの結合
スライド中に、同じ数字のタイルが衝突すると1つのタイルに結合され、その値が合計されます（例: 2 + 2 = 4）。結合されたタイルは次のスライドまで再び結合することはありません。（例えば、[2,2,2,2]を左にスライドさせると、[4,4,0,0]になる。）

- 新しいタイルの生成
各スライドの後、空いている位置に新しいタイル（2または4）がランダムに生成されます。生成されるタイルは、2が90%、4が10%の確率です。

- ゲームの終了
プレイヤーがタイルをスライドさせる操作ができなくなった場合（すべてのグリッドが埋まり、結合できるタイルがない状態）、ゲームオーバーとなります。

- 目標
数字「2048」のタイルを作成することが一応目標です。ただ、2048を作ったあともさらに高いスコアを目指してプレイを続けることができます。

# 準備
プロジェクトは[stack](https://docs.haskellstack.org/en/stable/)で作成しました。
ちなみにstackはmacであれば、[Homebrew](https://brew.sh/ja/)を使って簡単にインストールできます。
```shell
brew install stack
``` 
今回のプロジェクトは以下のコマンドで作成しました。
```shell
stack new game2048
```

# コード
早速コードを全部貼っちゃいます。
今回はすべて、`Main.hs`に書く設計にしました。

```haskell
module Main (main) where

import Control.Monad.State
import Control.Monad (when)
import Data.List (intercalate, transpose)
import Data.Maybe (fromJust)
import System.Random (randomRIO)
import System.Console.ANSI (Color(..), SGR(..), ConsoleLayer(..), ColorIntensity(..), setSGRCode)
import System.Exit (exitSuccess)

----------
-- Grid --
----------

type Grid = [[Int]]
type Pos = (Int, Int)

-- グリッドのサイズ
size :: Int
size = 4

-- 空のグリッドを作成する関数
emptyGrid :: Grid
emptyGrid = replicate size (replicate size 0)

-- グリッドをコンソールに描画する関数
renderGrid :: Grid -> IO ()
renderGrid grid = do
    mapM_ (putStrLn . renderRow) grid

-- グリッドを更新する関数
updateGrid :: Grid -> [(Pos, Int)] -> Grid
updateGrid = foldl updateCell
  where
    updateCell g ((r, c), val) = take r g
                            ++ [updateRow (g !! r) c val]
                            ++ drop (r + 1) g
    updateRow row c val = take c row ++ [val] ++ drop (c + 1) row

-- 一行をレンダリングするための補助関数
renderRow :: [Int] -> String
renderRow row = "|" ++ intercalate " | " (map formatTile row) ++ "|"

-- 固定幅と色で1つのタイルをフォーマットする補助関数
formatTile :: Int -> String
formatTile 0 = "    "  -- 空のセルはスペースとして表現
formatTile n = coloredString (tileColor n) (padString $ show n) 

-- タイルを揃えるためのパディングを追加
padString :: String -> String
padString s = let padding = replicate (4 - length s) ' ' in padding ++ s

-----------
-- Color --
-----------

-- 数値に対応する色を指定する関数
tileColor :: Int -> Color
tileColor n = case n of
    2    -> Yellow
    4    -> Green
    8    -> Cyan
    16   -> Blue
    32   -> Magenta
    64   -> Red
    128  -> Yellow
    256  -> Green
    512  -> Cyan
    1024 -> Blue
    2048 -> Magenta
    _    -> Red -- 2048以上のタイル用

-- 文字列に色を適用する関数
coloredString :: Color -> String -> String
coloredString color s = setSGRCode [SetColor Foreground Vivid color] ++ s ++ setSGRCode [Reset]

-----------
-- Game --
-----------

type Game a = StateT Grid IO a
data Direction = L | R | U | D

-- 入力文字列を方向に変換する関数
convertToDirection :: String -> Maybe Direction
convertToDirection "w" = Just U
convertToDirection "a" = Just L
convertToDirection "s" = Just D
convertToDirection "d" = Just R
convertToDirection _ = Nothing

-- タイルを結合し、サイズを正規化する関数
mergeTiles :: Int -> [Int] -> [Int]
mergeTiles n tiles = normalize n (merge (filter (/= 0) tiles))

-- ゼロを取り除き、長さを正規化
normalize :: Int -> [Int] -> [Int]
normalize n xs = take n (xs ++ repeat 0)

-- タイルを結合する補助関数
merge :: [Int] -> [Int]
merge [] = []
merge [x] = [x]
merge (x:y:xs)
    | x == y    = x * 2 : merge xs
    | otherwise = x : merge (y : xs)

-- 指定した方向にタイルを移動する関数
applyMove :: Direction -> Grid -> Grid
applyMove direction grid = case direction of
    L -> map (mergeTiles size) grid
    R -> map (reverse . mergeTiles size . reverse) grid
    U -> transpose $ map (mergeTiles size) (transpose grid)
    D -> transpose $ map (reverse . mergeTiles size . reverse) (transpose grid)

-- ランダムな位置にタイルを生成する関数
generateRandomTile :: Grid -> IO (Maybe Grid)
generateRandomTile grid = do
  let emptyTiles = [(r, c) | r <- [0..size-1], c <- [0..size-1], grid !! r !! c == 0]
  if null emptyTiles then return Nothing
  else do
        newTilePos <- (emptyTiles !!) <$> randomRIO (0, length emptyTiles - 1)
  　　　-- 値をランダムに選択（90%が2、10%が4）
        newTileValue <- randomRIO (1, 10 :: Int) >>= \x -> return $ if x <= 9 then 2 else 4
        -- 新しいタイルでグリッドを更新
        return $ Just $ updateGrid grid [(newTilePos, newTileValue)]

-- ゲームオーバーかどうかを判定する関数
checkGameOver :: Grid -> Bool
checkGameOver grid = all (== grid) [applyMove d grid | d <- [L, R, U, D]]

-- ゲームのメインループ 
runGame :: String -> Game ()
runGame consoleLog = do
    grid <- get

    liftIO $ do
        putStrLn "(Use WASD to move the tiles, and Q to quit.)"
        renderGrid grid
        putStrLn $ "Score: " ++ show (sum $ map sum grid)
    
    if checkGameOver grid
      then do
        liftIO $ do
            putStrLn $ coloredString Red "Game Over!"
            exitSuccess  -- プログラムを終了
      else do
        liftIO $ putStrLn consoleLog

    playerInput <- liftIO getLine

    -- Qキーでゲームを終了する 
    when (playerInput == "q") $ do
        liftIO $ do
          putStrLn "Thanks for playing!"
          exitSuccess  -- プログラムを終了

    -- WASDキーで移動する
    case convertToDirection playerInput of
        Just direction -> do
            let movedGrid = applyMove direction grid
            newGrid <- liftIO $ generateRandomTile movedGrid
            case newGrid of
                Just g -> do
                    put g
                Nothing -> return ()
        Nothing -> do
            runGame $ coloredString Yellow "Invalid input. Try again."
    runGame ""

-- ゲームを初期化して実行するメイン関数
main :: IO ()
main = do
    firstGrid <- fromJust <$> generateRandomTile emptyGrid 
    secondGrid <- fromJust <$> generateRandomTile firstGrid
    evalStateT (runGame "") secondGrid

```
githubは[こちら](https://github.com/suupia/game-2048)
`game2048`のディレクトリ内で以下を実行すれば遊べると思います！
```shell
stack build
stack run
```

# 基本方針
グリッドは、`type Grid = [[Int]]`で表し、数字がないタイルは`0`で表します。
`merge`が実際の結合を行っている関数で、`mergeTiles`が今回の状況に合うようにいい感じに`merge`をラップした関数です。

# 詰まったところ
## import文のエラー
```shell
not found: Could not load module ‘Control.Monad.State’
It is a member of the hidden package ‘mtl-2.3.1’.
```
というエラーが出ました。
これは、`packgae.yaml`の`dependencies`の部分を修正することで直ります。
```yaml
dependencies:
- base >= 4.7 && < 5
- mtl >= 2.3 && < 3
- ...（他の依存関係）
```

## プログラムを終了させる方法
[System.Exit](https://hackage.haskell.org/package/base-4.20.0.1/docs/System-Exit.html)の`exitSuccess`を使用します。

## ログをまとめて出力する
`Invalid input. Try again.`のような警告の文章はユーザーの入力に近い場所、つまり、一番下に出力したいです。しかし、コンソールに出力する処理は`runGame`関数のプレイヤーの入力を受け取る前だけで行いたいです。そこで、`consoleLog`という`String`の引数を追加して、次の`rungame`で警告がある際は表示するというようにしました。
何もログで出力する必要がない場合は、空の文字列を出力して、行数が崩れないようにしました。



# 感想
知らない関数がたくさんありました...
要精進です！


# 参考記事
https://nicaudinet.github.io/2024/10/14/watersort-haskell/