はじめに
本記事は Haskell Advent Calendar 2021 17日目の記事です。
- 想定読者: Haskell による TUI プログラミング、ゲームプログラミングに興味がある方
- 本記事のゴール: TUI で動作するゲームの作り方および流れを理解する
- 使用ライブラリのバージョン:
brick-0.62
,vty-5.33
- ソースコード: https://github.com/matonix/panepon
brick と vty
今回使用する主要なライブラリは brick と vty です。
vty は様々なターミナルで動作する TUI (テキストユーザインタフェース) ライブラリ
ncurses のラッパーで、ターミナルを介した入力(キー・マウスイベント等)や出力(ターミナルへの描画やテキストへの着色等)に関する API を提供します。
追記: 「vtyはncursesのラッパーである」という記載は誤りで、ライブラリの目的に類似した点はあるが、ラッパーではなく全く異なる実装である、とご指摘を頂きました。
brick は vty のラッパーで、vty だけでは記述が煩雑になりがちな TUI におけるウィジェット(ウィンドウ・プログレスバー等)を作成・合成する高レベルな API を提供します。
今回 TUI パネポンを作成するにあたっては、
- アプリケーションの骨子については brick のメインループ制御関数を使う
- 細かい描画の制御(パネルの色等)は vty の関数を使う
といった方針で上記ライブラリを利用します。
パネポンとは何か
パネポンとは任天堂より発売されたアクションパズルゲーム パネルでポン の略称です。
パネルでポンの公式サイト の説明を引用すると、
カーソルで囲まれた2つのパネルを入れ替えることにより、縦、 横に3つ以上そろうと消えます。パネルは上から降ってくるのでは なく、画面下方からセリ上がってきます。そして、天井にパネルが 当たると「ゲームオーバー!」
とのことです(なにかしらプレイ動画を見るのが早いと思います)。
本記事では最も単純なエンドレスモード(ゲームオーバーまでのスコアを競うモード)の実装を目指します。
brick を使ったメインループの管理
まずは、アプリケーションの中核となるデータ型 App
の設定と、App を一定時間で更新するメインループの仕組みについて説明します。
brick の関数やデータ型はできる限りモジュール名から省略せずに書きますが、brick が持つ典型的な機能は Brick
モジュール からそのまま利用できます(頻出する関数やデータ型は再エキスポートされています)。
アプリケーションを表すデータ型
Brick.Main.App
は TUI アプリケーションを宣言的に組み上げる際に中心となるデータ型で、以下のフィールドを持ちます。
data App s e n =
App { appDraw :: s -> [Widget n]
, appChooseCursor :: s -> [CursorLocation n] -> Maybe (CursorLocation n)
, appHandleEvent :: s -> BrickEvent n e -> EventM n (Next s)
, appStartEvent :: s -> EventM n s
, appAttrMap :: s -> AttrMap
}
Appには3つの型変数があり、それぞれ
- s: プログラムの状態の型
- e: イベントの型
- n: リソース名の型
を表します。
「s: プログラムの状態の型」には、アプリケーションのあらゆる状態(ゲームの状態、ゲームモード、コンフィグ情報等)を与えます。基本的にはレコードを使って書くことになります。
「e: イベントの型」には、vty が発生させるイベントや、アプリケーション固有のイベントの型を与えます。主な使い方はスレッド間通信のメッセージなどでしょう。スレッド間通信のために BChan
という通信チャネルが用意されているので、BChan
を使った通信をあとで紹介します。1
「n: リソース名の型」には、ウィジェットを識別するためのデータ型を与えます。複数存在する場合は直和型で書くことになります。これは1つのアプリが複数のカーソル位置(≒入力フォーム)を持ちうる場合に、どのカーソルが選択されているかを識別したり、マウスイベントがどのカーソル位置上で発生したかを識別するために使います。今回はウィジェットのカーソルを切り替えることを想定していないので単一のリソース名のみになります。2
各フィールドに与える関数は次の通りです:
-
appDraw
にはアプリケーションの状態に従いウィジェットの描画方法を決定する関数を与えます -
appChooseCursor
には TUI 画面上で表示するカーソルを選択する関数を与えます- 今回はカーソルを使用しないので、単に
Brick.Main.neverShowCursor
を与えています - 一般には、TUI アプリには複数のカーソル位置があるので、アプリケーションの状態に従い、どのカーソルにフォーカスするか決める必要があります3
- 今回はカーソルを使用しないので、単に
-
appHandleEvent
には、vty 経由で流れてくるイベント(キー入力や端末サイズ変更など)や、アプリケーション固有のイベント、マウスイベントをハンドリングする関数を与えます-
BrickEvent n e
として各種イベントが与えられるので、それぞれの場合に対するイベントハンドラを書くことになります - イベントハンドラは
EventM n (Next s)
型の値を返す必要があります。EventM
としては様々な副作用を扱うことができますが、基本的には Event handler functions で与えられるAPI(continue
,halt
など)を使って書くことになります。基本的には次状態s'
を計算してcontinue s'
を返しておけばOKです
-
-
appStartEvent
にはアプリケーションの最初に一度だけ実行するイベントを書きます- ウィジェットの初期状態を与えるのが主な使い方ですが、今回は単に
return
を与えておきます。
- ウィジェットの初期状態を与えるのが主な使い方ですが、今回は単に
-
appAttrMap
にはウィジェットの属性(文字の色や背景色)のマップを与えます。- 属性に名前をつけてキーとしておくことで、統一された表現やテーマの変更等が行いやすくなります
- 今回は特にパネルの色の表現の指定に使います
メインループの実装
さて、このApp動かすためにメインループを記述します。 Brick.Main
には様々なメイン関数が用意されていますが、今回はBrick.Main.customMain
を使います。パネポンのように高頻度の画面更新を行うゲームでは、更新のサイクルを管理してゲーム内の1フレーム(60フレーム毎秒の場合は 16.66...ミリ秒)毎に画面更新イベント(Tickと呼ぶこととします)を発行するスレッドを、ゲームの次の盤面を計算するスレッドとは独立に作っておくことが望ましいです4 。そのため、Tick イベントを定期的に発行するスレッドを forkIO
を使って作ります。メインループするスレッドと Tick イベントを発行するスレッドとの間で通信を行うために、BChan
という通信チャネルを使います。
BChan
は(ドキュメントから察するに)Bounded channelのことで、有限のサイズを持つチャネルです。Brick.BChan.newBChan
にサイズを与えて新しい BChan
を作り、書き込みを行うスレッドと読み込みを行うスレッドに渡しておきます。書き込み側は Brick.BChan.writeBChan
によってチャネルに書き込みを行います。読み込み側はBrick.BChan.readBChan
を使ってチャネルからデータを取り出します(チャネルに対する操作は IO
アクションです)。
BChan
によるスレッド間通信を使ってメインループを実装すると以下のようになります:
data Tick = Tick
main :: IO ()
main = do
chan <- newBChan 10 -- サイズは適当気味
forkIO $ -- Tickイベントを発行するスレッド
forever $ do
writeBChan chan Tick
threadDelay $ 1000000 -- 100万マイクロ秒 = 1秒
let builder = V.mkVty V.defaultConfig
initialVty <- builder
let initialApp = ... -- プログラムの初期状態。詳細略
void $ customMain initialVty builder (Just chan) app initialGame -- メインスレッド
forkIO には threadDelay
で一定時間待ちつつ writeBChan
で Tickイベントを投げる無限ループを渡しています。また、customMain
には vty やチャネル、App、そしてプログラムの初期状態(型 s
の値)を渡しています。
customMain
に渡された chan
に渡されたデータは、App の appHandleEvent
フィールドに登録された関数の中で、AppEvent e
の形でパタンマッチして取り出すことができます。今回は AppEvent Tick
ですね。
appHandleEvent
フィールドに登録する関数を handleEvent
として実装していきます。先述の AppEvent e
に対応する処理に加えて、VtyEvent e
の形式で得られるキー入力等のイベントにも対応しなければなりません。ここで意識したいのは、非同期にやってくるキー入力が1フレーム以内に複数あった場合に、必要があれば同時押しとして認識する必要があるということです。今回の実装では、VtyEvent
としてやってくるキー入力に対応するキーイベントをバッファに積み、AppEvent Tick
が来た時に1フレーム分の処理を行う関数にバッファの中身をまとめて渡す、という方法を取ります。
実装を簡略化したものを以下に示します:
type Name = ()
data Event = Up | Down | Left | Right | Swap | Lift
data Game = Game
{ events :: [Event], -- キーイベント
board :: Board, -- 現在の盤面(ゲームの状態)。詳細略
}
-- 1フレーム分処理を進める関数。詳細は `next :: [Event] -> Board -> Board` という状態遷移系の関数に委譲。
step :: Game -> Game
step (Game events board) =
let nextBoard = next events board
in Game [] nextBoard
-- キー入力をバッファに積む関数。
next :: Event -> Game -> Game
next event (Game events board) = Game (event : events) board
-- イベントを処理する関数。関数のシグネチャは `App` の `appHandleEvent` フィールドが要求する形式に従う。
handleEvent :: Game -> BrickEvent Name Tick -> EventM Name (Next Game)
handleEvent g (AppEvent Tick) = continue $ step g
handleEvent g (VtyEvent (V.EvKey V.KUp [])) = continue $ next Up g
handleEvent g (VtyEvent (V.EvKey V.KDown [])) = continue $ next Down g
handleEvent g (VtyEvent (V.EvKey V.KRight [])) = continue $ next Right g
handleEvent g (VtyEvent (V.EvKey V.KLeft [])) = continue $ next Left g
handleEvent g (VtyEvent (V.EvKey (V.KChar 'x') [])) = continue $ next Swap g
handleEvent g (VtyEvent (V.EvKey (V.KChar 'z') [])) = continue $ next Lift g
handleEvent g (VtyEvent (V.EvKey (V.KChar 'q') [])) = halt g
handleEvent g (VtyEvent (V.EvKey V.KEsc [])) = halt g
handleEvent g _ = continue g
Swap
, Lift
はパネポンにおける「交換」「せり上げ」に当たる操作ですが、知らなくてもとりあえずz, xキーで何かしら操作できるという認識で大丈夫です。handleEvent
の結果の型は複雑な形をしていますが、基本的にはゲーム内の操作に対して next
関数を呼び出して次状態を作り、Brick.Main.continue
に渡すか、ゲームを止める操作に対して Brick.Main.halt
を呼ぶ感じで書けます。
ここまででメインループを実現する方法について述べたので、次は各ループの1フレームを構成する処理について述べていきます。
メインループの1フレームを作る
前節のコード中で、1フレーム分の処理は next :: [Event] -> Board -> Board
という状態遷移系の関数に委譲すると書きました。本節ではこの関数の中身を作っていきます。
ゲームの状態を表す階層構造
現在のパネポンのデータ構造の概要は以下となります:
各要素をデータ型として定義し、env を最上位とする階層構造で管理します。各要素の概要は以下のような感じです:
- env はアプリケーションの環境周りの情報を持ちます。現時点ではフレームレートの設定とgameのみを持っています
- game はゲームの状態に関する情報を持ちます。複数の盤面(board)を持ちうると想定していますが、現時点では単一のboardを持ちます
- boardはパネポンにおける盤面の情報を持ち、最も多くの情報を抱えています:
- grid: パネルを配置できる範囲および、せり上がりの状態を管理
- panel: 0個以上存在するパネルの状態を管理
- cursor: パネルを操作するカーソルの状態を管理
階層的な状態遷移系
game以下の要素に関しては、各要素の型を state
、受け取るイベントの型を input
とすると、どの要素も next :: input -> state -> state
という形式の関数を持ちます。これはまさしく状態遷移系の遷移関数です。
どの要素も受け取った input
と現在の state
に従って次の状態を作り出します。子要素も状態遷移系である場合は、その子要素が受け取れる input
を子要素の next
に渡していきます。このようにして木構造全体で状態の更新が行われます。
状態遷移系の全てをカバーするとキリがないので、今回はパネルの状態遷移に注目していきます。
まずは、パネルの状態を表すデータ型を見てみましょう(deriving 節などは省略しています):
data Panel = Panel
{ _color :: Color, -- パネルの色
_state :: State, -- パネルの状態
_count :: Count, -- パネルの状態遷移条件に関わるカウンタ
_pos :: Pos, -- パネルの位置
_chainable :: Bool -- 連鎖可能状態か否か
}
data Color = Red | Green | Cyan | Purple | Yellow | Blue
data State = Init | Idle | Move Direction | Float | Fall | Vanish | Empty
data Direction = L | R
type Count = Int
type Pos = (Int, Int)
特に重要なのは _state :: State
であり、パネルの状態の中核となるデータです。これらの状態(およびカウンタや位置など)を変化させるのは親の構造から呼び出されるイベント群です( input
に該当します):
data Event
= Tick -- 時間経過(1フレーム)
| Lift -- せり上がり通知
| CountFinish State Count -- 一定フレームかけて実行されるイベントの完了通知
| Bottom State Count Bool -- 自分の真下のパネルの状態変化に関する通知
| Available -- ゲーム開始直後や、せり上がり完了によってパネルが操作可能になることを示す通知
| Combo -- パネルが揃って消失を開始する通知
| Swap Direction -- パネルが左か右に移動されたことを示す通知
今回はイベントの発行は発行者(盤面)の責任(チェックはパネル側では行わない)という原則の下で設計を行っています。そうすることで、パネル自身の状態変化に集中でき、状態遷移系が簡潔になると考えました。(その代わり、イベントを発行する盤面側のチェックは複雑化します)
状態遷移系の状態と入力を挙げたところで、いよいよ遷移関数 next
について見ていきます5:
next :: Event -> Panel -> Panel
next Tick panel = panel & count %~ succ
next Lift panel = panel & posY %~ succ
next (CountFinish (Move d) c) panel@(_state -> Move d') | _count panel == c && d == d' = panel & state .~ Idle & countReset
next (CountFinish Float c) panel@(_state -> Float) | _count panel == c = panel & state .~ Fall & countReset
next (CountFinish Fall c) panel@(_state -> Fall) | _count panel == c = panel & state .~ Fall & countReset & posY %~ pred
next (CountFinish Vanish c) panel@(_state -> Vanish) | _count panel == c = panel & state .~ Empty & countReset
next (Bottom Empty _ ch) panel@(_state -> Idle) = panel & state .~ Float & countReset & chainable .~ ch
next (Bottom Fall _ ch) panel@(_state -> Idle) = panel & state .~ Fall & countReset & chainable .~ ch
next (Bottom Float c _) panel@(_state -> Fall) = panel & state .~ Float & countReset
next (Bottom b _ _) panel@(_state -> Fall) | isGround b = panel & state .~ Idle & countReset
next (Bottom b _ _) panel@(_state -> Idle) | isGround b && _count panel > 0 = panel & state .~ Idle & chainable .~ False
next Available panel@(_state -> Init) = panel & state .~ Idle
next Combo panel@(_state -> Idle) = panel & state .~ Vanish & countReset
next (Swap L) panel@(_state -> s) | isMovable s = panel & state .~ Move L & countReset & posX %~ pred
next (Swap R) panel@(_state -> s) | isMovable s = panel & state .~ Move R & countReset & posX %~ succ
next _ panel = panel
状態遷移のパターンが膨大かつ煩雑なので、一番上の Tick
イベントについて見てみましょう。右辺では、現在のパネルの状態のうち、カウンタ count
に対して succ
をして更新しています(新しい値を作っています)。このように、多くの状態遷移では状態の一部の値のみを更新して次状態を得る、ということを行うので、レコードの一部をフォーカスして書き換えるlensの記法が便利です(実際のところbrickを使ったサンプルプログラムでlensを使っていたので真似してみたというところが大きいですが…)。
その他の状態遷移について話しているとキリがないので割愛しますが、パネルに関連する状態遷移は以下の図のような感じになります:
この辺の設計には反省点があって、実はパネルに関しては1フレーム中に複数回の状態遷移をさせなければならないことが判明し、状態遷移に関して優先度付けを行う必要が出てきました。
図中に点線の状態遷移と実線の状態遷移があると思いますが、これは点線部分を先に処理しないと想定通りの振る舞いを達成できなかったために生まれたものです。いろいろ試行錯誤した結果、盤面側で行うイベント通知処理は以下の順で行うことになりました:
この処理を実装した盤面側の処理は次のようなコードになります(後でいくつか機能を足したので図に存在しない処理も含みます):
nextPanels :: ColorGenerator g => Rule -> Events -> G.Grid -> C.Cursor -> Panels -> g -> Int -> Int -> (Panels, g, Int, Int, Bool, Bool)
nextPanels rule events grid cursor panels gen combo chain = (ss, gen', combo', chain', chain_up, dead)
where
te = tickEvent panels -- 時間経過
(le, gen') = liftEvent te gen grid -- せり上がり通知
cf = countFinish rule le -- 一定フレームかけて実行されるイベントの完了通知
bc = bottomCondition cf -- 自分の真下のパネルの状態変化に関する通知
ec = emptyCollect bc -- 消失後のパネルの片付け
cs = comboStart ec -- 3つ以上の同色パネルが揃っているものをマーキング
combo' = comboCount cs -- 同時消しカウント
(chain', chain_up) = chainCount cs chain -- 連鎖カウント
ss = swapStart cs events cursor -- パネルの交換通知
dead = checkDead grid ss -- ゲームオーバー状態の検査
この辺は処理間の依存関係を明らかにするために do 記法とか使って書いても良かったのですが、今回はざっくり where 節に書いてしまっています。
状態のあり方を工夫したとしても、1フレーム中の逐次処理が不要になるのかどうかはちょっと怪しいので、逐次処理の解消については妥協しています(ルールが複雑すぎました)。
他のカーソルやグリッドといった要素に関しても、パネルと同様に状態遷移系を書いて、盤面からイベントを発行してあげれば、1フレーム中の処理が実現できます。
brick のウィジェットと vty によるリッチな表現
brick のウィジェットでウィンドウを作る
それではいよいよ brick のウィジェット機能を使って描画していきましょう。
ウィジェットの描画を制御する関数は Brick.Widgets.*
にあります。
例えば以下のような情報ウィンドウを作るとしましょう:
┏━━━━━━Info━━━━━━┓
┃ combo: 0 ┃
┃ chain: 1 ┃
┃ score: 30 ┃
┃ lift: 0% ┃
┃ forceMode: False ┃
┃ liftEvent: Stop ┃
┃ duration: 0.0063ms ┃
┗━━━━━━━━━━━━━━┛
情報の中身はさておき、ウィンドウの枠やタイトルの設定が必要です。これにはBrick.Widgets.Border.borderWithLabel
の引数にタイトルのウィジェットと中身のウィジェットを与えます。タイトルはただの文字列なので、Brick.Widgets.Core.str
に文字列を与えて文字列だけのウィジェットを作りましょう。中身は複数のデータが集まっていますが、情報を垂直に並べたければ、ウィジェットのリストを垂直に整列させる Brick.Widgets.Core.vBox
を使います(水平方向なら hBox
です)。
ウィジェット周りのAPIはウィジェットを作る関数と、ウィジェットを合成する関数で構成されています。罫線付きウィンドウなどの大きなウィジェットであっても、他のウィジェットと水平 Brick.Widgets.Core.(<+>)
および垂直 Brick.Widgets.Core.(<=>)
に並べることができ、パディング Brick.Widgets.Core.pad*
や中央揃え Brick.Widgets.Center.center
などで内側のウィジェットの配置をコントロールすることもできます。
ウィジェットのレンダリングに関するコードは量が多いので詳細は省きますが、基本的に game → board → (cursor, grid, panel) のようにゲームの状態の階層をトップダウンで辿りがなら、個別の部品をレンダリングしていく流れになります。
vty のアトリビュートでパネルやカーソルを表現する
次にパネルの表示部分を見ていきましょう。盤面にはパネルのリストがありますので、各座標データを取り出せばレンダリングすべき位置を計算できます。
パネルの表現ですが、背景に色付けして、記号部分(文字)を黒にすることとします。vty では文字の装飾を Graphics.Vty.Attributes.Attr
というデータ型で表現しており、文字のスタイル(太字や斜体等)、前景色(文字の色)、背景色、URLを指定できます。Brick.Util.on
を使えば前景と背景を簡単に合成できます。例えば赤背景に黒文字なら black `on` red
といった具合です。❤、■、▲ などの文字は、半角スペース2つ分の横幅を持ちますが、文字としては半角1文字分として扱われるので、レンダリングするときはスペースを開ける必要があります。
カーソルの表現ですが、パネルと対比させるために白文字で[ ]
を書くこととしましょう。背景の色は変えたくないので、現在の属性を継承するために Graphics.Vty.Attributes.currentAttr
を使います。あとは、Graphics.Vty.Attributes.withForeColor
で前景色を白に変えましょう。カーソルを表示させる位置ですが、パネルとパネルの間に半角スペースがあるはずなので、その位置にカーソルを表示させることとします。すると、パネルの上にいい感じにカーソルが被さって表示されます(ちょっと左にずれてる感じがありますが)。
最後に AttrMap
の使い方を見ていきます。App
の定義の中に appAttrMap :: s -> AttrMap
(s はプログラムの状態の型)というフィールドがありました。 Brick.AttrMap.attrMap :: Attr -> [(AttrName, Attr)] -> AttrMap
は基となる属性(Graphics.Vty.Attributes.Attr
)をベースに brick で使える属性の辞書を作る関数です。キーは AttrName
という型ですが、OverloadedStrings
拡張をオンにしていれば文字列リテラルをAttrName
として認識してくれます(型の明示は必要かも)。
今回は以下のような AttrMap
を作って、appAttrMap
に与えました(プログラムの状態による属性の変化は特に考えてないので const theMap
です)。
{-# LANGUAGE OverloadedStrings #-}
import Brick ( attrMap )
import qualified Graphics.Vty as V -- 多くの関数はここから再エキスポートされています
theMap :: AttrMap
theMap =
attrMap
V.defAttr
[ (redAttr, V.black `on` V.red),
(greenAttr, V.black `on` V.green),
(cyanAttr, V.black `on` V.cyan),
(purpleAttr, V.black `on` V.magenta),
(yellowAttr, V.black `on` V.yellow),
(blueAttr, V.black `on` V.blue),
(cursorAttr, V.currentAttr `V.withStyle` V.bold `V.withForeColor` V.white)
]
redAttr :: AttrName
redAttr = "redAttr"
-- 以下略
完成形
ということで、brick と vty を使ってテキストベースでパネポンを再現することができました!
本家ではパネルが消えるとき左上から順番に消えていくアニメーションがあるのでそちらも再現してみたいですね。
ソースコードはこちらです: https://github.com/matonix/panepon
おわりに
なお、本記事の構成とは異なり、実際には
- メインループの1フレームの設計
- brickによるウィジェット配置
- vtyによる色付け
という流れで作っています。1フレームにおける挙動を状態遷移系として表現しておけば、TUIでもGUIでも同じロジックが使い回せるんじゃないか?という試みが元になっていて、TUIのウィジェットを作るライブラリを探していたところbrickがいいなという判断に至ったという感じです。
また、ゲームを構成する部品をすべて状態遷移系として表現し、状態の更新を親から子への単方向の伝達に限定することで、コアのロジックを純粋に保ちつつ、凝集度が高いコードを書くことができるのではないか?という試みでもありました。
明日の 12/18 は @hxf_vogel さんの記事です。お楽しみに!
参考リンク
-
Brick User Guide
- 公式の brick ユーザーガイドです。
App
やWidget
など、中核をなす概念が説明されています
- 公式の brick ユーザーガイドです。
-
Sam Tay | Introduction to Brick
- Sam Tay氏による brick の紹介です。snake ゲームを作る流れになっていて、アプリケーションの構造の大部分はこちらを参考にさせていただきました
-
Haskellで作るTUIアプリ - Qiita
- @kwhrstr1206氏による brick の紹介です。本記事では扱っていない複数のフォーカスを持つアプリケーションを作成されています
-
イベントの型のその他の情報については公式ガイドの Using Your Own Event Typeもご覧ください。 ↩
-
気になる方は公式ガイドの Resource Namesをご覧ください。 ↩
-
参考: Haskellで作るTUIアプリ で作成されたアプリ wuzzkell ではフォーカス位置を巡回する機能が実装されていました。 ↩
-
次状態(ゲームの次の盤面)の計算にかかる時間を考慮して
threadDelay
することが難しいためです。 ↩