photo credit: AlexDROP CRW_1655 – Frosen Leaves tableau via photopin (license)

↓ の記事で説明しているルーレットアプリをElmで作成した。
Unfair Roulette : インチキできるルーレットアプリを作成した

どのような実装になっているかをソースコードや図表とともに解説する。

1. アプリについて

2. 状態遷移の仕様

このアプリは複数の状態(シーン)からなり、状態受け取ったメッセージの組み合わせに応じて処理がなされる。

状態遷移図

次の状態遷移図では、各状態において、メッセージ(青矢印)が出された場合にどの状態に遷移するかを示している。
メッセージに応じて状態が変化したり、状態はそのままでもViewが更新されたりする。
初期状態は図中左端のEditingRouletteである。

state-transtion-dyagram

それぞれの状態とメッセージの意味は次の通り。

状態

状態 説明
EditingRoulette ルーレット(を構成するアイテム)を編集している状態
RouletteSpinning ルーレットが回転している状態
RouletteSpinningTowardsStop ルーレットが停止のタイミングを図りながら回転している状態
RouletteStopped ルーレットが停止した状態
ResultShowed ルーレットの結果が表示された状態

メッセージ

メッセージ メッセージを受け取った際の処理
Increment 指定されたアイテムの設定値を1増やす
Decrement 指定されたアイテムの設定値を1減らす
ChangeLabel 指定されたアイテムのラベルを、入力欄の文字列に変更する
ChangeCount 指定されたアイテムの設定値を、入力欄の数値に変更する
Clear 指定されたアイテムの設定値とラベルをクリアする
Cheat 指定されたアイテムでルーレットが停止するように設定する
OnClickStart ルーレット回転開始の条件を満たしていれば StartSpinningRoulette メッセージを出す
StartSpinningRoulette ルーレットの回転を開始する
SpinRoulette ルーレットの回転を進める。
ルーレットが停止条件を満たしていれば StopRoulette メッセージを出す。
(SpinRouletteメッセージは、Subscription によりRouletteSpinningorRouletteSpinningTowardsStop状態の場合に定期的に出される)
AdjustDecayRateForSmoothStop ルーレットの回転角速度の減衰率を調整する
StopRoulette ルーレットの回転を停止する
ShowResult ルーレットの結果をダイアログで表示する
HideResult ルーレットの結果ダイアログを閉じる

NOTE 厳密には、例えばIncrementIncrement Counterという型をしているが省略して表記している。他のメッセージも同様。

3. 各処理の実装詳細

一部の処理についてどう実装したかを紹介する。
前提として、各処理では以下に定義する型が登場する。

-- MODEL

type alias Model =
  { scene: Scene
  , counters: Counters
  , maxCounters: Int
  , rotation: Float -- in percentage
  , rotationVelocity: Float
  , decayRate: Float
  , goalRotation: Float
  , pointedCounter: Counter
  , cheatedGoalRange: Maybe Range
  }

type alias Counters = List Counter

type alias Counter =
  { id: Int 
  , label: String
  , count: Int
  }

type alias FanShape =
  { offset: Float
  , percentage: Float
  , color: String
  }

type alias Color = String

type alias Colors = List Color

type Scene
  = EditingRoulette
  | RouletteSpinning
  | RouletteSpinningTowardsStop
  | RouletteStopped
  | ResultShowed

type alias Range =
  { min: Float
  , max: Float
  }

type alias RotationRange =
 { min: Float
 , max: Float
 }

これより先を読む上で把握しておいてほしいのはCounter型とFanShape型だ。

  • Counterはルーレットを構成する選択肢の呼び名と数値(例えば票数)を表す。ユーザーの操作で編集される。
  • FanShapeは、ルーレットを構成する扇形(の描画に必要な情報)を表す。Counterを用いて算出される。

これまで、この記事中でルーレットのアイテムと呼んでいたものはCounterでありFanShapeである。

3.1 ルーレットの編集処理

editing-roulette

Increment : 指定されたアイテムの設定値を1増やす

modelにはCounters型の値を持たせており、指定されたアイテム(counter)だけを1増やしたCounters値を計算し、新しいModelを返す。

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case (msg, model.scene) of
    (Increment counter, EditingRoulette) ->
      let
        (front, back) = separateIntoFrontAndBack model.counters counter
        updatedCounter = { counter | count = counter.count + 1}
        updatedCounters = front ++ [updatedCounter] ++ back
      in
        ({ model | counters = updatedCounters }, Cmd.none)

ここでseparateIntoFrontAndBackは、更新後のCounters値を計算するためのヘルパー関数である。

Decrement : 指定されたアイテムの設定値を1減らす

指定されたアイテム(counter)だけを1減らしたCounters値を計算する。0以下にはならない。

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case (msg, model.scene) of
-- (中略)
    (Decrement counter, EditingRoulette) ->
      if 0 < counter.count then
        let
          (front, back) = separateIntoFrontAndBack model.counters counter
          updatedCounter = { counter | count = counter.count - 1}
          updatedCounters = front ++ [updatedCounter] ++ back
        in
          ({ model | counters = updatedCounters }, Cmd.none)
      else
        (model, Cmd.none)

ChangeCount : 指定されたアイテムの設定値を、入力欄の数値に変更する

Increment, Decrement1ずつしか増減されないが、ChangeCountでは直接指定した数値に設定できる。数値入力欄への入力がされる度にChangeCountがされる。

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case (msg, model.scene) of
-- (中略)
    (ChangeCount counter newCount, EditingRoulette) ->
      let
        (front, back) = separateIntoFrontAndBack model.counters counter
        updatedCounter = { counter | count = String.toInt newCount |> Maybe.withDefault (if newCount == "" then 0 else counter.count) }
        updatedCounters = front ++ [updatedCounter] ++ back
      in
        ({ model | counters = updatedCounters }, Cmd.none)

(書いていて今更気付いたが、IncrementDecrementなんてメッセージは不要で、ChangeCountだけで全て担える。DRYしよう。)

ChangeLabel : 指定されたアイテムのラベルを、入力欄の文字列に変更する

ChangeCountとほぼ同じ。指定されたアイテム(counter)のラベルを、入力文字列newLabelで置き換えた新しいModel値を返す。

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case (msg, model.scene) of
-- (中略)
    (ChangeLable counter newLabel, EditingRoulette) ->
      let
        (front, back) = separateIntoFrontAndBack model.counters counter
        updatedCounter = { counter | label = newLabel}
        updatedCounters = front ++ [updatedCounter] ++ back
      in
        ({ model | counters = updatedCounters }, Cmd.none)

Clear : 指定されたアイテムの設定値とラベルをクリアする

指定されたアイテムの、ラベルと設定値を""(空文字)と0にしたModel値を返す。

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case (msg, model.scene) of
-- (中略)
    (Clear counter, EditingRoulette) ->
      let
        (front, back) = separateIntoFrontAndBack model.counters counter
        updatedCounter = { counter | count = 0, label = "" }
        updatedCounters = front ++ [updatedCounter] ++ back
      in
        ({ model | counters = updatedCounters }, Cmd.none)

3.2 ルーレットの描画処理

ルーレットの描画(というより、扇形の描画)には、[Elm] SVGで円グラフを表示する方法の記事の方法を用いた。細部は異なるが、基本的なやり方は同じ。

viewRoulette : Counters -> Colors -> Float -> Html Msg
viewRoulette counters colors rotation =
  let
    counts = List.map (\counter -> toFloat counter.count) counters
    total = List.sum counts
    percentages = List.map (\count -> 100.0 * count / total) counts
    offsets =
      List.foldl (\percentage acc -> List.append acc [(Maybe.withDefault 0.0 <| List.maximum acc) + percentage]) [0.0] percentages
        |> List.map ((+) rotation)
    fanShapes = List.map3 (\offset percentage color -> FanShape offset percentage color) offsets percentages colors
  in
    svg
      [ viewBox "0 0 63.6619772368 63.6619772368", width "500px", style "transform" "rotate(90deg)" ]
      (List.append (List.map (\fanShape -> viewFanShape fanShape) fanShapes) [viewRoulettePointer])

viewFanShape : FanShape -> Html Msg
viewFanShape fanShape =
  let
    strokeDashoffset_ = String.fromFloat <|  -fanShape.offset -- Fan shape direction is opposite to Svg.circle, becouse of the specification of dassArray. So I negat fanshape.offset.
    strokeDasharray_ = String.fromFloat fanShape.percentage ++ " " ++ (String.fromFloat <| 100.0 - fanShape.percentage)
  in
    circle
      [ cx "31.8309886184", cy "31.8309886184", r "15.9154943092"
      , fill "#ffffff", fillOpacity "0.0"
      , stroke fanShape.color, strokeWidth "31.8309886184", strokeDashoffset strokeDashoffset_, strokeDasharray strokeDasharray_ ]
      []

viewRoulettePointer : Html Msg
viewRoulettePointer =
  polygon
    [ points "63.6619772368,29.5309886184 63.6619772368,34.3309886184 57.6619772368,31.8309886184"
    , style "fill" roulettePointerColor
    ]
    []

viewRoulette関数では、まず引数で渡されたcountersrotation(ルーレットの回転角[%]) をもとにして描画する扇形の情報fanShapesを算出する。
それを、扇形を描画するviewFanShape関数に適用することで全ての扇形を描画する(つまりルーレットが描画される)。
また、ルーレットの針をviewRoulettePointer関数で描画する。

3.3 ルーレットの針が指すアイテムを特定する処理

以下のpointedCounter_に、針が指すアイテム(Counter値)を計算しバインドしている。
途中で用いるcalculateCollisionRangeswillBeNewlyPointedの関数についても続けて説明する。

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case (msg, model.scene) of
-- (中略)
    (SpinRoulette _, RouletteSpinning) ->
      let
        pointedCounter_ =
          calculateCollisionRanges model.counters model.rotation
            |> List.map2 (\counter collisionRange -> Tuple.pair counter <| willBeNewlyPointed model.rotationVelocity collisionRange) model.counters
            |> List.filter (\(counter, willBeNewlyPointed_) -> willBeNewlyPointed_)
            |> List.head
            |> Maybe.withDefault (model.pointedCounter, False)
            |> Tuple.first
-- (以下略)

ここでcalculateCollisionRangesは、それぞれのアイテムがルーレット上に占める角度の範囲(始端角[%]〜終端角[%])を計算する関数であり、次のように定義される。

calculateCollisionRanges : Counters -> Float -> List RotationRange
calculateCollisionRanges counters rotation =
  let
    counts = List.map (\counter -> toFloat counter.count) counters
    total = List.sum counts
    percentages = List.map (\count -> 100.0 * count / total) counts
    offsets =
      List.foldl (\percentage acc -> List.append acc [(Maybe.withDefault 0.0 <| List.maximum acc) + percentage]) [0.0] percentages
        |> List.map ((+) rotation)
  in
    List.map2 (\percentage offset -> RotationRange offset <| offset + percentage) percentages offsets

let in中で計算されているoffsetsは始端角[%]のリストであり、percentagesはルーレット上で占める角度の範囲の大きさ[%]のリストである。
これらを用いて最後の行List.map2 (\percentage offset -> RotationRange offset <| offset + percentage) percentages offsetsで、ルーレット上に占める角度の範囲を求めている。

次にwillBeNewlyPointedは、回転が1つ分進むことによって指定のアイテムが新たにルーレットの針で指されるようになるかどうかを計算する関数であり、以下のように定義される。
(このアプリでは、ルーレットが回転している間、針が指しているアイテムのラベルを表示し続けるような仕様にしている。針が指すアイテムは回転が進むうちに変化するため、回転する毎に調べるように実装している。結果を知るだけであればルーレット停止時に調べれば済むのだが、今回はそれでは不十分なのだ。)

willBeNewlyPointed : Float -> RotationRange -> Bool
willBeNewlyPointed rotationVelocity collisionRange  =
  if carryDownUnder 200.0 100.0 (collisionRange.max + rotationVelocity) > 100.0
    && carryDownUnder 200.0 100.0 (collisionRange.min + rotationVelocity) <= 100
  then
    True
  else
    False

carryDownUnder : Float -> Float -> Float -> Float
carryDownUnder maximum decrementStep value =
  if value < maximum then
    value
  else
    carryDownUnder value decrementStep <| value - decrementStep 

willBeNewlyPointed関数内のみにおいては、ルーレット上でのアイテムの始端角/終端角を0[%]〜200[%]で表している。(他の箇所では0[%]〜100[%]で表現している。)
このとき、ルーレットの回転が進むことによって新たに針に指されるようになるアイテムは次の4つの条件をすべて満たす。

  1. 回転が進む前の、始端角が100[%]以下である
  2. 回転が進む前の、終端角が100[%]以下である
  3. 回転が進んだ後の、始端角が100[%]以下である
  4. 回転が進んだ後の、終端角が100[%]より大きい

ここで、条件1と2は常に成り立つので、わざわざ考慮しなくてよい。(なぜなら、「他の箇所では0[%]〜100[%]で表現している」という前提のため。回転が進む前の始端角/終端角は、「他の箇所」から渡される値。)
よってwillBeNewlyPointed関数は、条件3と4を満たすようなアイテム(counter)に対してはTrueを返し、そうでないものにはFalseを返す。

ちなみに、ヘルパー関数のcarryDownUnderは、回転角が100[%]以上進むような場合でも始端角/終端角を0[%]〜200[%]の範囲に収めるために用いる。

3.4 ルーレットの停止箇所をランダム or インチキで決定する処理

cheat

インチキでルーレットの停止箇所を決定するには、ラベルの左隣にある四角形をクリックする。
これによりCheatメッセージが送られ、以下に示すコードでCheatメッセージを処理することで、ルーレットを停止させる回転角の範囲model.cheatedGoalRangeを、クリックしたアイテム範囲に設定する。

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case (msg, model.scene) of
-- (中略)
    (Cheat counter, EditingRoulette) ->
      let
        cheatedGoalRange_ =
          if counter.count > 0 then
            calculateCollisionRanges model.counters 0.0
              |> List.take counter.id
              |> List.reverse
              |> List.head
              |> Maybe.andThen (\maybeRange -> Just <| Range (100.0 - maybeRange.max) (100.0 - maybeRange.min))
          else
            Nothing
      in
        ({model | cheatedGoalRange = cheatedGoalRange_}, Cmd.none)

この後「Start」ボタンがクリックされるとOnClickStartメッセージが送られ、以下のコードに示すように、停止させる回転角の範囲(先程インチキで決めた値)内で停止角をランダムに決定する。
このとき、もしもインチキがされていないならば、0[%]〜100[%]の範囲(コード中のfairGoalRange)内でランダムに停止角を決定する(つまり、フェア)。

これにより決定した停止角をStartSpinningRouletteメッセージで送り、ルーレットの回転が開始される。

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case (msg, model.scene) of
-- (中略)
    (OnClickStart, EditingRoulette) ->
      let
        goalRange =
          Maybe.withDefault fairGoalRange model.cheatedGoalRange
      in
        (model, Random.generate (StartSpinningRoulette initialDecayRate) <| Random.pair (Random.float initialVelocityRange.min initialVelocityRange.max) (Random.float goalRange.min goalRange.max))

    (StartSpinningRoulette decayRate_ (initialVelocity, goal), EditingRoulette) ->
      if isThereEnogthCountersToStart model.counters then
        ({ model | scene = RouletteSpinning, decayRate = decayRate_,rotationVelocity = initialVelocity, goalRotation = goal }, Cmd.none)
      else
        (model, Cmd.none)
-- (以下略)


-- ヘルパー関数: ルーレットを開始するための最低限の個数のアイテムが設定済みかどうかをチェックする
isThereEnogthCountersToStart : Counters -> Bool
isThereEnogthCountersToStart counters =
  counters
    |> List.filter (\counter -> counter.count > 0)
    |> List.length
    |> (<) 1

ちなみに、ルーレットの回転は サブスクリプション により定期的になされる。

subscriptions : Model -> Sub Msg
subscriptions model =
  case model.scene of
    RouletteSpinning ->
      Time.every 30 SpinRoulette
    RouletteSpinningTowardsStop ->
      Time.every 30 SpinRoulette
    _ ->
      Sub.none

3.5 ルーレットを予定された停止角で停止させる処理

ルーレットの回転を進めるSpinRouletteメッセージをupdate関数内で処理する際、
ルーレットの角加速度が閾値を初めて下回るような場合に、減衰係数model.decayRateを調整するためのAdjustDecayRateForSmoothStopメッセージを送信する (コード掲載は省略)。

これにより減衰係数を1に近づけ、ルーレットの回転が高々あと1周だけゆっくりと続くようにする。
このときのSpinRouletteメッセージの処理中で、willReachGoal関数によりルーレットが停止角に達するかどうかを調べ、達するようであればルーレットの回転を停止する。

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case (msg, model.scene) of
-- (中略)
    (AdjustDecayRateForSmoothStop decayRate_, RouletteSpinning) ->
      ({ model | decayRate = decayRate_, scene = RouletteSpinningTowardsStop }, Cmd.none)

    (SpinRoulette _, RouletteSpinningTowardsStop) ->
      let
        pointedCounter_ =
          calculateCollisionRanges model.counters model.rotation
            |> List.map2 (\counter collisionRange -> Tuple.pair counter <| willBeNewlyPointed model.rotationVelocity collisionRange) model.counters
            |> List.filter (\(counter, willBeNewlyPointed_) -> willBeNewlyPointed_)
            |> List.head
            |> Maybe.withDefault (model.pointedCounter, False)
            |> Tuple.first
        (rotation_, rotationVelocity_) = updateRotation model.rotation model.rotationVelocity model.decayRate
      in
        if willReachGoal model.goalRotation model.rotation model.rotationVelocity then
          update (StopRoulette milliSecondsToKeepRouletteStopUntilResult) model
        else
          ({ model | rotation = rotation_, rotationVelocity = rotationVelocity_, pointedCounter = pointedCounter_}, Cmd.none)

    (StopRoulette forMilliSeconds, RouletteSpinningTowardsStop) ->
      ({ model | scene = RouletteStopped}, Process.sleep forMilliSeconds |> Task.perform (always ShowResult))
-- (以下略)


-- ヘルパー関数: 回転が1つ進むことで予定の停止角に達するかどうかを調べる
willReachGoal : Float -> Float -> Float -> Bool
willReachGoal goal rotation velocity =
  if rotation < goal && goal <= rotation + velocity then
    True
  else
    False

4. もし作り直すなら…

この記事の残りでは、出来栄えに対する不満点、開発を振り返っての反省点を述べていく。
未来の自分よ、気が向いたらこれを元に修正してくれ。

4.1 スマートフォンで使いやすいデザインにする

このアプリはPCよりもスマートフォンで利用するシーンがメインになると予想される。

しかし開発当時はPCでレイアウトを確認しながらコーディングを進めてしまい、スマートフォンで確認したのは開発が一通り終わった頃になってしまった。

スクリーンショットを見ればわかるように、スマートフォンでは小さく表示されてしまい操作しづらい。

開発終盤でレスポンシブにしようと試みたが、思うようなレイアウトにならず断念してしまった。
むしろPCは切り捨てて、完全にモバイル用にしてしまうのでも良かったと思う。

👇PC画面での表示
with-pc

👇スマートフォンでの表示
with-mobile

4.2 裏技的なコードよりも王道的なコードで書く

既に述べたように、ルーレットの描画(というよりも、扇形の描画)にはこちらの記事の方法を用いた。
[Elm] SVGで円グラフを表示する方法

この方法では時計回りに扇形を並べることで円グラフを作ることになる。
私はコードをなるべくシンプルにするためには反時計回りに扇形を並べるべきと考えていたが、
この方法を使いつつも反時計回りに並べようとすると、うまく表示されなかった。
(オフセットや描画順などを泥臭く調整すれば上手くいったかもしれないが…)

SVGで描画する際の角度については反時計回りが正の回転方向なのだが、時計回りの方向に扇形を並べてしまったことで向きの不一致が起こった。このため所々に細かな調整が必要になり、コードが若干理解しづらくなってしまったし、コーディングの際に私自身が混乱した。

4.3 ルーレットが自然な止まり方をするようなロジックにする

このルーレットアプリは予定された停止角で停止するように終盤で角速度の減衰係数の調整を行うが、このために止まり方が不自然に見えてしまうことがある。
(最後にしぶとく回転し続ける。)

これを解決する一案として、調整を最後に行うのではなく開始直後に行う方法がある。
まず、ルーレットの初速度と減衰係数より調整を一切行わない場合の停止角を求め、目的としている停止角との差分を算出する。
回転開始直後の1回目の回転進みで、回転角が丁度算出した差分の値になるように回転させる。
(1回目の回転進みに限らず、算出した差分と回転初速度の大小関係の程度によっては、数回目としても良いと思う。)
そこから先は減衰係数を本来の値にして回転を進める。
すると目的としている停止角で停止し、停止の仕方も自然なものとなる。

しかし今度はルーレット回転開始直後の回転の仕方に違和感が出るのではないか? と疑問に思うかもしれないが、
回転開始からしばらくは角速度が大きく、また角速度が調整される時間は非常に短い(数十ミリ〜数百ミリ秒)のでほぼ違和感はないと思われる。

カテゴリー: Tips

hahnah

はーな Webエンジニア。モバイルアプリも少々。Elmが好き。

0件のコメント

コメントを残す

メールアドレスが公開されることはありません。