最近Elmの勉強をはじめました。
勉強をかねて、何かしらゲームを作りたいと思い、ぷよぷよを作ってみることに。。。
これがなかなか難しかったので、備忘録。
とりあえずコードは
https://github.com/m-masataka/elm-puyo
に上げます。
ちょっと色々と直したので、記事も修正
Modelの定義
type alias Model =
{ board: Board -- 盤面
, grippedPuyo: List (Int, Cell) -- 操作中のぷよ
, status: Status -- ゲームのステータス
, nextPuyo: List Cell -- 次に待機しているぷよ
, timecounter: Int -- ゲームの経過時間(frame的な処理に使う)
, chain : Int -- 連鎖記録用
, score : Int -- スコア記録用
}
type alias Board =
List Cell
盤面(Board)
Boardは2次元配列を用意したいところだが、Elmで2次元配列を扱うのはそこそこめんどくさい。
ということで、1次元配列で模擬する。
Cell
にぷよの属性を持たせることで、どのマスにどのぷよがいるかがわかる。
type Cell
= Red
| Blue
| Green
| Yellow
| Ojama
| Empty
| None
Noneは言わば壁を表している。ぷよの移動さきがNone(壁)だったら移動不可といった具合に使える様に用意した。
↓なイメージ
| | | |青| | |壁|
| | | |赤| | |壁|
| | | | | | |壁|
...
View
Viewは結構簡単で、board -> row -> cellで入子に表示する様にする。
Viewを表示する場合はListとmapがめっちゃ便利。
ポイントとしては、 Tuple.pairで2次元配列っぽく変換しているところ。
viewBoard : Model -> Html Msg
viewBoard model =
let
lx = List.range 0 columns |> List.map (\a -> a * rowsLen )
ly = List.range 1 columns |> List.map (\a -> a * rowsLen - 1 )
mat = List.map2 Tuple.pair lx ly
in
div [ class "grid-container" ]
[ viewEnd model
, div []
<| List.map (\(x, y) -> viewRow x y model) mat
]
viewRow : Int -> Int -> Model -> Html Msg
viewRow i j model =
div [class "grid-row"] <|
if i /= 0 then
let
board = if model.status == Normal then
model.board |> Board.replace model.grippedPuyo
else
model.board
in
List.map viewCell ( board |> Array.fromList |> Array.slice (i + 1) (j + 1) |> Array.toList)
else
[]
viewCell : Cell -> Html Msg
viewCell cell =
case cell of
Empty ->
div [class "grid-cell"]
[ img [ class "puyo"
]
[]
]
Red ->
div [class "grid-cell"]
[ img
[ src "/img/puyo_1.png"
, class "puyo"
]
[]
]
...
Subscription
ぷよぷよ の盤面変更はKey操作と自由落下が必要なので、2つ定義する。
複数定義する場合はSub.batchを使うらしい。
onKeyDownの項目はUpdateの項目で説明する。
Time.everyで一定間隔毎でイベントを発火してくれる。(これは盤面切り替えやぷよの落下に利用)
-- SUB
subscriptions model =
Sub.batch
[ onKeyDown (Decode.map KeyMsg keyDecoder )
, Time.every 50 Tick
]
特に動きがないWebUIについてはSub.noneにしておくみたい。
Main
(最初に書くべきだが)main文を書く
-- MAIN
main =
Browser.element
{ init = init
, update = update
, subscriptions = subscriptions
, view = view
}
もはや呪文に近いが、副作用のあるコードを書く場合はBrowser.element
というBrowserモジュールを利用する。
Browserモジュールについては
https://qiita.com/jinjor/items/245959d2da710eda18fa:title
がわかりやすかった。
Update
ここが一番しんどい
キー入力
ElmのBrowserパッケージはとても便利で、Eventsパッケージの中で大体のイベントをキャッチできる。
ただ、スマホのピンチ・スワイプとかはサポートできてなかった気がする。
今回onKyeDownを利用。
import Browser.Events exposing (onKeyDown)
...
Sub.batch
[ onKeyDown (Decode.map KeyMsg keyDecoder )
...
-- Update
case msg of
KeyMsg keyname ->
let
ngp=
case model.status of
Normal ->
move_ keyname
_ ->
model.grippedPuyo
in
( { model | grippedPuyo = ngp }, Cmd.none )
...
-- Decoder
keyDecoder : Decode.Decoder KeyName
keyDecoder =
Decode.map toKeyName (Decode.field "key" Decode.string)
toKeyName : String -> KeyName
toKeyName string =
case string of
"ArrowDown" ->
Down
"ArrowRight" ->
Right
"ArrowLeft" ->
Left
"Tap" ->
SpinLeft
"SwipeDown" ->
FallDown
_ ->
SpinLeft
subscriptionの中でKeyDownのイベントを受信し、keyDecoderでStringをKeyName型(自分で定義)に変換。
Updateで定義しているKeyMsgで受け取ったKeyNameを処理している。
あとはKeyMsgの中でmodelを更新すれば動きが完成する。
盤面のステータス
以下の様なステータスを用意した。(StartとEndは同じでも用意かも)
- Start
- ゲーム開始
- New
- 盤面上のぷよの移動が完了し、新しいぷよが配置される状態
- 新しいぷよが出現する場所が埋まってたらEndに遷移
- それ以外はNormalに遷移
- Normal
- 操作中のぷよが自由落下する状態
- キー入力を受付可能
- ぷよが下に進めなくなったらFallに遷移
- Fall
- ぷよが落ちる状態
- キー入力不可
- 落下するぷよが無ければRemoveに遷移
- Remove
- ぷよが連結して、消える状態
- キー入力不可
- ぷよを削除した場合はFallに遷移
- 削除するぷよが無ければNewに遷移
- End
- ゲーム終了
操作ぷよ動作
操作対象ぷよ(grippedPuyo)をキー入力に応じて動かす。
簡単には、KeyNameによって下のマスに動く(i + rowsLen)か、横に動く(i+1)か等を決めている。
動いた先がEmptyでは無い場合は、移動不可として元のぷよの位置を返す。
回転の動作は若干面倒なので、spin関数を別途作った。
move_ : KeyName -> List (Int, Cell)
move_ keyname =
let
spin_ : List (Int, Cell) -> Board -> List (Int, Cell)
spin_ l b =
let
(i1, c1) = List.getAt 0 l |> Maybe.withDefault (0, None)
(i2, c2) = List.getAt 1 l |> Maybe.withDefault (0, None)
np_ =
if i1 - i2 > 0 then
if (i1 - i2 |> abs) == rowsLen then
[ (i1, c1), (i1 - 1, c2) ]
else
[ (i1, c1), (i1 + rowsLen, c2) ]
else
if (i1 - i2 |> abs) == rowsLen then
[ (i1, c1), (i1 + 1, c2) ]
else
[ (i1, c1), (i1 - rowsLen, c2) ]
in
if check_ np_ then
np_
else
[(i1, c2), (i2, c1)]
check_ : List (Int, Cell) -> Bool
check_ l =
List.map (\(i, c) -> Board.get i model.board) l |> List.all (\c -> c == Empty)
check__ = (\l -> if check_ l then l else model.grippedPuyo)
in
case keyname of
Down ->
check__ <| List.map (\(i, c) -> (i + rowsLen, c) ) model.grippedPuyo
Left ->
check__ <| List.map (\(i, c) -> (i - 1, c) ) model.grippedPuyo
Right ->
check__ <| List.map (\(i, c) -> (i + 1, c) ) model.grippedPuyo
_ ->
spin_ model.grippedPuyo model.board
ぷよの落下
かなりゴチャゴチャした関数になってしまったが、やっていることは、
fall__は再帰関数。というより、Boarの操作はほとんど再帰処理。
盤面の右下から捜査していって、自分の下のマスがEmptyだったら下に落下。
これを繰り返すことで全部のぷよが下に詰まる。
fall_ : Board -> ( Board, Bool )
fall_ board_ =
let
fall__ : List (Int, Cell) -> Board -> Board
fall__ l b =
case List.head l of
Just (i, c) ->
let
fall___ : Int -> Int
fall___ j =
if Board.get (i + rowsLen * j) b == Empty then fall___ (j + 1) else j - 1
in
if List.member (Board.get i b) Board.normal &&
Board.get (i + rowsLen) b == Empty then
Board.replace [(i, Empty), (i + rowsLen * (fall___ 1), c)] b
|> fall__ (List.tail l |> Maybe.withDefault [])
else
fall__ (List.tail l |> Maybe.withDefault []) b
Nothing ->
b
nb = fall__ (List.indexedMap Tuple.pair board_ |> List.reverse) board_
in
( nb , nb /= board_)
ぷよの削除
ぷよの連結判定は↓のサイトがわかりやすい。
https://ponk.jp/cpp/el/puyopuyo
1つのぷよを起点に周りのぷよを捜査していく。同じ色だったら更に捜査。別の色だったらreturnって感じで再起処理を進めていく感じ。
Elmだとこういったコードは書きやすいね。
remove_ : Board -> (List (List Int), Bool)
remove_ board_ =
let
chain_ : Int -> Cell -> List Int -> List Int
chain_ i c l =
let
next = [i - rowsLen, i - 1, i + 1, i + rowsLen]
c_ = Board.get i board_
in
if c == c_ then
List.filterNot (\n -> List.member n l) next
|> List.map (\n -> chain_ n c ( i :: l ))
|> List.concat |> (\li -> i :: li)
else
[]
picked_ = List.indexedMap Tuple.pair board_
|> List.filterNot (\(i, c) -> c == Empty || c == None )
|> List.map (\(i, c) -> chain_ i c [] )
|> List.filter (\l -> List.length l > 3)
in
( picked_, List.length picked_ > 0 )
Score
スコア計算は適当に↓を参考に計算
https://bozitoma.com/games/point-calculator/
計算に必要な要素は
連鎖数・連結数・消した色数なので、remove fallでの処理の際に全部算出可能。
ここまでの完成版
だいぶ前だが、elm-gamesに入れてもらった
https://github.com/rofrol/elm-games
今の状態だとカクカク動いてるので、次はアニメーションにチャレンジかな