Edited at

Elmでクォータービューを実装する


前回のつづきから

本頁はElm2(完全版) Advent Calendar 2018の17日目の記事Elm3(予備) Advent Calendar 2018の18日目の記事です。

8日目の記事「SelectListを使ってElmでマス目を作成する」の続きになります。


elm-map-creater

https://github.com/dcsyhi/elm-map-creater

2D.png (4.8 kB)

前回までで、縦横2次元のマス目を描くことができました。ここからは3次元の描画に挑戦してみます。とはいえ、いきなり本物の3Dオブジェクトを作るのは難易度が高いので、今回はSRPGでよく見られるクォータービュー(等角投影図、マス目を斜め上から俯瞰したようなモデル)を作ることにします。


注意!!

今回使用したElmのバージョンは0.19です

また、本記事の内容はベストプラクティスではありません

筆者が学習も兼ねてトイアプリを作成した際のまとめですのでご了承ください



タイルチップを作る

クォータービュー面.png (20.0 kB)

上図の通り、タイルチップを構成するには3つの面を作ればよさそうです。

タイルチップを座標平面上に描くに当たっては、


  • タイルチップの幅や高さ

  • 原点のオフセット

  • 3つの面の描画に必要な、立体の各交点に当たる座標の配置を行うリスト

なども必要です。これらの作成に当たっては、以下のページを参考にしました。

クォータービューの描画例

https://takachan.hatenablog.com/entry/2016/01/15/000750


タイルチップを作る

-- 以下、タイルチップの実装

{- h:タイルチップの高さ(pixel単位)
w:タイルチップの幅(pixel単位)
offsetX:原点をX方向に指定値だけ移動させる
offsetY:原点をY方向に指定値だけ移動させる
-}

h : Float
h =
44

w : Float
w =
44

offsetX : Float
offsetX =
50

offsetY : Float
offsetY =
130

{-  タイルチップの3面を描画するため、交点を配置するためのリストを作る 

top : タイルチップの上面
leftSide : タイルチップの左側面
rightSide : タイルチップの右側面
-}

top : List ( Float, Float )
top =
[ ( 0, h / 4 ), ( w / 2, 0 ), ( w, h / 4 ), ( w / 2, h / 2 ) ]

leftSide : List ( Float, Float )
leftSide =
[ ( 0, h / 4 ), ( w / 2, h / 2 ), ( w / 2, h ), ( 0, 3 * h / 4 ) ]

rightSide : List ( Float, Float )
rightSide =
[ ( w / 2, h / 2 ), ( w, h / 4 ), ( w, 3 * h / 4 ), ( w / 2, h ) ]

3つの面を作る関数は、直接数字を入れずに、whの値を変えることでタイルチップの幅と高さを後で変更できるようにしておきます。


直交座標⇒クォータービュー座標への変換

次に、1次変換の考え方1を用いて、直交座標(x,y)をクォータービュー座標(X, Y)に変換します。

{- quarterX:直交座標(x,y)を使ってクォータービュー座標(X)を求める関数

quarterY:直交座標(x,y)を使ってクォータービュー座標(Y)を求める関数
-}

quarterX : Float -> Float -> Float
quarterX x y =
offsetX + 0.5 * w * (x + y)

quarterY : Float -> Float -> Float
quarterY x y =
offsetY + 0.25 * h * (-x + y)

前回作成したcreateSelectListを利用して、タイルチップをクォータービュー座標(X,Y)に並べるための関数も作成しておきます。関数の作り方の流れは、


  • いったん、マス目の交点(x,y)の集合を得るcreateXYListを作成

  • 関数stackQuarterListで、createXYListをクォータービュー座標に正射影する

です。

-- クォータービューを実装するために座標変換後のリストを作成する

-- 最後にzipでまとめて(x,y)のタプルの集合を得る
zip : List a -> List b -> List ( a, b )
zip xs ys =
List.map2 Tuple.pair xs ys

createXYList : Int -> List ( Float, Float )
createXYList n =
let
a =
createSelectList n
|> SelectList.attempt SelectList.delete
|> SelectList.toList
|> List.map .x
|> List.map toFloat

b =
createSelectList n
|> SelectList.attempt SelectList.delete
|> SelectList.toList
|> List.map .y
|> List.map toFloat
in
zip a b

-- クォータービュー座標(X,Y)のSelectListを作成する
outputQuarterList : Int -> Int -> SelectList.SelectList ( Float, Float )
outputQuarterList n stackNum = -- n列目の格子点を作る (ちなみにstackNumは高さ方向に積み上げるための引数)
createXYList n
|> List.map (\( x, y ) -> ( quarterX x y, quarterY x y )) -- (x,y)を(X, Y)方向に正射影
|> List.map (\( x, y ) -> ( x, y - (stackNum |> toFloat) * h / 4 )) -- 後で高さ方向に積み上げるために必要
|> SelectList.fromList
|> Maybe.withDefault (SelectList.singleton ( 0.0, 0.0 )) -- Maybeを除く処理

試しにelm replで出力してみると、うまくXY平面上に新たな格子点を作成することができています。

11.png (84.0 kB)


タイルチップをクォータービュー座標に配置する

この格子点上に、先ほど作ったタイルチップの3面(top, leftSide, rightSide)をそれぞれ配置する関数を作成します。SelectList.indexSelectList.selectedを使ってインデックスや要素自体を取得しながら、countを回して再帰処理で列方向(X方向)のリストを作っています(リスト作成の考え方は前回と同じなので割愛します。X,Y方向は参考にしたサイトと同方向です)。


  • strokeLinejoin StrokeLinejoinRoundを使うと3つの面が滑らかに繫がるのでうまく立体に見える


  • leftSide |> List.map (\( c, d ) -> ( c + x, d + y ))のように、面を構成する格子点を描画させたい位置に平行移動させる


  • List.map (\( x, y ) -> ( x, y - (stackNum |> toFloat) * h / 4 ))で、stackNumを増やせば高さ方向にもタイルチップの積み上げができる

などが関数を作るときに工夫したポイントです。

{- createTile:タイルチップを座標平面上に配置するための関数 -}

createTile : Int -> Int -> Int -> List (Svg msg)
createTile n i stackNum = -- nは何列目か、iは何行目かに相当 (stackNumは以下略)
let
count =
stackQuarterList n stackNum
|> SelectList.selectWhileLoopBy i
|> SelectList.index

x =
stackQuarterList n stackNum
|> SelectList.selectWhileLoopBy i
|> SelectList.selected
|> Tuple.first

y =
stackQuarterList n stackNum
|> SelectList.selectWhileLoopBy i
|> SelectList.selected
|> Tuple.second
in
case count of
0 ->
[]

_ ->
[ polygon
[ SvgAt.fill (Fill <| Color.rgb255 189 192 186)
, stroke <| Color.rgb255 65 70 66
, strokeLinejoin StrokeLinejoinRound
, strokeWidth (px 1.0)
, strokeOpacity (Opacity <| 1.0)
, points <| (leftSide |> List.map (\( c, d ) -> ( c + x, d + y )))
]
[]
, polygon
[ SvgAt.fill (Fill <| Color.rgb255 189 192 186)
, stroke <| Color.rgb255 65 70 66
, strokeLinejoin StrokeLinejoinRound
, strokeWidth (px 1.0)
, strokeOpacity (Opacity <| 1.0)
, points <| (rightSide |> List.map (\( e, f ) -> ( e + x, f + y )))
]
[]
, polygon
[ SvgAt.fill (Fill <| Color.rgb255 189 192 186)
, stroke <| Color.rgb255 65 70 66
, strokeLinejoin StrokeLinejoinRound
, strokeWidth (px 1.0)
, strokeOpacity (Opacity <| 1.0)
, points <| (top |> List.map (\( a, b ) -> ( a + x, b + y )))
]
[]
]
{- 再帰を使ってcreateTileをリスト化 -}
createTileList : Int -> Int -> Int -> List (Svg msg)
createTileList n i stackNum =
case n of
0 ->
[]

_ ->
List.foldr (::) (createTile (n-1) i stackNum) (createTileList (n - 1) i stackNum)


クォータービューのマス目

後は並べるだけです。それでは試しに土台を作ってみましょう2

{- maxElementは原点を含む要素の最大数 -}

maxElement : Int
maxElement =
7

-- X方向のマス目の数 (実際のマス目の数は指定した値-1)
boardWidth : Int
boardWidth =
7

-- Y方向のマス目の数 (実際のマス目の数は指定した値-1)
boardHeight : Int
boardHeight =
maxElement

{- クォータービューの土台を作る -}
drawBaseTile : Int -> Int -> Int -> List (Svg msg)
drawBaseTile i j stackNum = -- (i,j)を両方とも動かしてみる
case i of
0 ->
List.foldr (::)
(createTileList 0 j stackNum
|> List.reverse
)
<|
drawBaseTile 0 (j - 1) stackNum
_ ->
case j of
0 ->
[]
_ ->
List.foldr (::)
(createTileList (i-1) (j-1) stackNum
|> List.reverse
)
<|
drawBaseTile i (j - 1) stackNum

{- drawBaseTileをHtml化する -}
drawQuarterBoard : Int -> Int -> Int -> Html Msg
drawQuarterBoard i j stackNum =
case (i,j) of
(0,0) ->
svg [][]
_ ->
svg [ SvgAt.width (px 1000), SvgAt.height (px 1000), viewBox 0 0 1000 1000 ] <|
List.foldr (::) [] (drawBaseTile i j stackNum)

view : Model -> Html Msg
view model =
if maxElement <=5 then
div [][h1 [] [ Html.text "maxElement number is too small. Please input more big number." ]]
else
div []
[ drawQuarterBoard boardWidth boardHeight 0
]

boardWidthは列方向、boardHeightは行方向のマス目の数を指定する引数です。

両方"7"を指定した場合、以下の図のように6×6のマス目が作成されます3

board.png (11.5 kB)


色付きタイルを積み上げて動かしてみる

マス目の土台が作れたので、最後の仕上げとして上に色付きタイルを積み上げて、更に2次元のマス目と連動するような動きが出来るようにしたいと思います。

まず、色付きタイル(赤)の実装の例を以下に示します。

関数の中身が長くなるので分割していること以外は土台のタイルの実装と変わらないので、説明は割愛します。

-- クォータービューに色付きタイルを置くための関数(赤、黄、緑、青の4色を用意)

stackTileRed : Int -> Int -> Int -> Html Msg
stackTileRed n i stackNum =
let
count =
List.range 0 i
|> SelectList.fromList
|> Maybe.withDefault (SelectList.singleton 0)
|> SelectList.selectWhileLoopBy i
|> SelectList.selected
in
case count of
0 ->
svg [ SvgAt.width (px 1000), SvgAt.height (px 1000), viewBox 0 0 1000 1000 ] <|
innerTileRed n (boardHeight-1) stackNum
_ ->
svg [ SvgAt.width (px 1000), SvgAt.height (px 1000), viewBox 0 0 1000 1000 ] <|
innerTileRed n i stackNum

{- 上記の関数の中身が煩雑になることを防ぐため、内部関数を用意 -}
innerTileRed : Int -> Int -> Int -> List (Svg msg)
innerTileRed n i stackNum =
let
count =
outputQuarterList n stackNum
|> SelectList.selectWhileLoopBy i
|> SelectList.index

x =
outputQuarterList n stackNum
|> SelectList.selectWhileLoopBy i
|> SelectList.selected
|> Tuple.first

y =
outputQuarterList n stackNum
|> SelectList.selectWhileLoopBy i
|> SelectList.selected
|> Tuple.second
in
case count of
0 ->
[]

_ ->
[ polygon
[ SvgAt.fill (Fill <| Color.rgb255 208 16 76)
, stroke Color.black
, strokeLinejoin StrokeLinejoinRound
, fillOpacity (Opacity <| 1.0)
, strokeWidth (pt 1.0)
, points <| (top |> List.map (\( a, b ) -> ( a + x, b + y )))
]
[]
, polygon
[ SvgAt.fill (Fill <| Color.rgb255 208 16 76)
, stroke Color.black
, strokeLinejoin StrokeLinejoinRound
, fillOpacity (Opacity <| 1.0)
, strokeWidth (pt 1.0)
, points <|
(leftSide
|> List.map (\( a, b ) -> ( a + x, b + y ))
)
]
[]
, polygon
[ SvgAt.fill (Fill <| Color.rgb255 208 16 76)
, stroke Color.black
, strokeLinejoin StrokeLinejoinRound
, fillOpacity (Opacity <| 1.0)
, strokeWidth (pt 1.0)
, points <|
(rightSide
|> List.map (\( a, b ) -> ( a + x, b + y ))
)
]
[]
]

さて、色付きタイルが作れたのでそれらをボード上に配置する訳ですが、うまく描画させないと画像の前後関係がおかしくなって、クォータービューが3次元に見えません。

以下の実装は少しテクニカルですが、

"最も後に追加された要素が最前面になる"というSVGの性質を逆手に取って、「個々の要素に対して無理やりインデックスを割り振り、最も奥に描画したいタイルのインデックスが若くなるようにソートする」 ことを行っています。インデックスが引数i,jに連動して変化するようにしているのがポイントです。

-- 各々の要素にindexを振るためにOrder型を用意

type alias Order =
{ index : Int
, element : Html Msg
}

-- indexが振られたタイルのリスト
orderTile : Int -> Int -> List Order
orderTile i j =
let
countRedFirst =
modBy (boardWidth-1) i

countBlueFirst =
modBy (boardWidth-1) (i + 1)

countGreenFirst =
modBy (boardWidth-1) (i + 2)

countYellowFirst =
modBy (boardWidth-1) (i + 3)

countRedSecond =
modBy (boardHeight-1) j

countBlueSecond =
modBy (boardHeight-1) (j + 1)

countGreenSecond =
modBy (boardHeight-1) (j + 2)

countYellowSecond =
modBy (boardHeight-1) (j + 3)

countOrderRed =
-countRedFirst + countRedSecond

countOrderBlue =
-countBlueFirst + countBlueSecond

countOrderGreen =
-countGreenFirst + countGreenSecond

countOrderYellow =
-countYellowFirst + countYellowSecond

arrangeStackCubes = [ Order countOrderRed <|
stackTileRed countRedFirst countRedSecond 1
, Order countOrderRed <|
stackTileRed countRedFirst countRedSecond 2
, Order countOrderRed <|
stackTileRed countRedFirst countRedSecond 3
, Order countOrderRed <|
stackTileRed countRedFirst countRedSecond 4
, Order countOrderRed <|
stackTileRed countRedFirst countRedSecond 5
, Order countOrderBlue <|
stackTileBlue countBlueFirst countBlueSecond 1
, Order countOrderBlue <|
stackTileBlue countBlueFirst countBlueSecond 2
, Order countOrderRed <|
stackTileBlue countBlueFirst countBlueSecond 3
, Order countOrderRed <|
stackTileBlue countBlueFirst countBlueSecond 4
, Order countOrderRed <|
stackTileBlue countBlueFirst countBlueSecond 5
, Order countOrderGreen <|
stackTileGreen countGreenFirst countGreenSecond 1
, Order countOrderGreen <|
stackTileGreen countGreenFirst countGreenSecond 2
, Order countOrderGreen <|
stackTileGreen countGreenFirst countGreenSecond 3
, Order countOrderYellow <|
stackTileYellow countYellowFirst countYellowSecond 1
, Order countOrderYellow <|
stackTileYellow countYellowFirst countYellowSecond 2
, Order countOrderYellow <|
stackTileYellow countYellowFirst countYellowSecond 3
, Order countOrderYellow <|
stackTileYellow countYellowFirst countYellowSecond 4
]
in
List.foldr(::) arrangeStackCubes
<| [Order -100 <| drawQuarterBoard boardWidth boardHeight 0]



  • countRedFirst = modBy (boardWidth-1) imodByを多用していますが、
    これは色付きタイルを土台内に留まらせるためのテクです

  • タイルを積み上げるためのstackNumを引数に追加していましたが、ここで使っています


  • Order -100 <| drawQuarterBoard boardWidth boardHeight 0の−100は土台が最初に描画されるための措置です
    (別に-100でなくても構いません)

最終的に、色付きタイル+土台のリストOrderTileList.sortBy .indexでインデックスが若い順にソートして、そのあと要素のみを取り出しています

drawQuarterObjects : Int -> Int -> Html Msg

drawQuarterObjects i j =
svg [ SvgAt.width (px 1000), SvgAt.height (px 1000), viewBox 0 0 1000 1000 ] <|
List.foldr (::)
[]
(orderTile i (j + 1) |> List.sortBy .index |> List.map .element)

後はボタンを配置して、ボタンを押すごとにカウントアップするような引数を用意すれば色付きタイルが動かせます(そこまで難しくないので説明は割愛します)

-- 縦横2次元の色付きタイルも(i,j)で動かす

drawObjects : Int -> Int -> Html Msg
drawObjects i j =
svg
[ SvgAt.width <| px <| 300
, SvgAt.height <| px <| 300
, viewBox 0 0 300 300
]
[ drawBoard maxElement
, drawRectRed (modBy (maxElement-1) i) (modBy (maxElement-1) j)
, drawRectBlue (modBy (maxElement-1) (i + 1)) (modBy (maxElement-1) (j + 1))
, drawRectGreen (modBy (maxElement-1) (i + 2)) (modBy (maxElement-1) (j + 2))
, drawRectYellow (modBy (maxElement-1) (i + 3)) (modBy (maxElement-1) (j + 3))
]

---- MODEL ----

type alias Model =
{ rowCount : Int
, columnCount : Int
}

init : ( Model, Cmd Msg )
init =
( { rowCount = 0
, columnCount = 0
}
, Cmd.none
)

---- UPDATE ----

type Msg
= NoOp
| MoveRow
| MoveColumn
| Reset

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
MoveRow ->
( { model | rowCount = model.rowCount + 1 }, Cmd.none )

MoveColumn ->
( { model | columnCount = model.columnCount + 1 }, Cmd.none )

Reset ->
init

NoOp ->
( model, Cmd.none )

---- VIEW ----

buttonMoveTile : Html Msg
buttonMoveTile =
div []
[ img [ src "/logo.svg" ] []
, h1 [] [ Html.text "Your Elm App is working!" ]
, button [ onClick MoveRow ] [ Html.text "縦に動く" ]
, button [ onClick MoveColumn ] [ Html.text "横に動く" ]
, button [ onClick Reset ] [ Html.text "リセット" ]
]

---- MODEL ----

view : Model -> Html Msg
view model =
div []
[ buttonMoveTile
, hr [] []
, drawObjects model.columnCount model.rowCount
, hr [] []
, drawQuarterObjects model.columnCount model.rowCount
]

完成!

http://35.221.113.240/

rendou.png (42.7 kB)


おまけ

今回のお題の目標が「高低差概念のあるマップを作る」だったので、その後TOのバルマムッサの町っぽいものをがんばって作ってみました ⇒ バルマムッサの町リスペクト

barumamussa.png (371.2 kB)

実装に関しては少し工夫している所があり、また機会があれば説明記事を上げたいと思います(タイルを積むこと自体はそんなに難しくないように実装したつもりなので、気合があれば↑は誰でも作れます)。

他のクォータービューのマップも作ってみたいですしね(^_^)

ここまで読んでいただいて、ありがとうございました!


参考資料

等角投影図について

http://tonbi.jp/Game/Essay/036/

回転移動の1次変換

http://www.geisya.or.jp/~mwm48961/kou2/linear_image3.html

クォータビューの作り方

http://2dgames.jp/2012/05/22/%E3%82%AF%E3%82%A9%E3%83%BC%E3%82%BF%E3%83%93%E3%83%A5%E3%83%BC%E3%81%AE%E4%BD%9C%E3%82%8A%E6%96%B9/

クォータービューの描画例

https://takachan.hatenablog.com/entry/2016/01/15/000750





  1. 数学的に気になる方は回転移動の1次変換を復習してみるとよいでしょう 



  2. (i,j)に実際の数字を入れるとき、負の数を入れたり指定を誤るとランタイムエラーが発生するので注意します 



  3. 縦横2次元のマス目を描画する際に作成した格子点を利用しているので、出力されるマス目の数は指定した引数の数-1です