6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Elmでぷよぷよを作る(その1)

Last updated at Posted at 2019-10-27

最近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
  • ゲーム終了

image.png
あとは定義通りに盤面とぷよを動かす。

操作ぷよ動作

操作対象ぷよ(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での処理の際に全部算出可能。

ここまでの完成版

ぷよぷよ .gif

だいぶ前だが、elm-gamesに入れてもらった
https://github.com/rofrol/elm-games

image.png

今の状態だとカクカク動いてるので、次はアニメーションにチャレンジかな

6
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?