F#とAltseedで関数型ライクなゲーム制作

初めに

(* おまじない *)

これはAmusementCreators AdventCalendar(ACAC) 2018の22日目の記事です。
adventar.org

最初に謝りますが、この記事は約4万2千字あります。
ソースコードが殆どですが、実質講座みたいになってますね。
興味があればゆっくり読んでみてください。

さて、「F#関数型プログラミングをしてゲームを作ろう!」という記事です。
ただしViewに使用しているゲームエンジンの仕様上、そちらはがっつりオブジェクト指向になってます。
お許しください。

今回の記事の対象者:

  • 張り巡らされた再代入や参照や継承関係に頭を痛めたことがある。
  • 汎用性の高い道具を組み合わせて見通しの良いプログラムを書くことに興味がある。
  • 関数型プログラミングに興味がある / 初心者だ。
  • 関数型プログラミングでゲームを作ることに興味がある。

また、最低限プログラミングとオブジェクト指向に触れたことがあることを前提としています。

今回作成するのはこんな感じのものです。


使用した画像は 白黒ヤギさん 様からお借りしました。 ありがとうございます。
45mix.net


また、GitHubリポジトリはこちらになります。
github.com




なかなか書き終わらず遅刻しました!
冬コミやばい!!
何もかもやばい!!!!!

目次です!


概要

以前、こんな記事を書きました。
wraikny.hatenablog.com

タイトルの通り、F#からゲームエンジンAltseedを使うチュートリアルのような記事です。
しかし、これはC#のコードをそのままF#で書き直しただけのオブジェクト指向プログラミングであり、F#の関数型言語の良さを活かせていません。

そこで今回は、描画と更新処理を分けるModel-View構造を採用します。描画部分でのみゲームエンジンを利用して、更新部分ではそれらに縛られない関数型の良さを活かしつつゲームを作る入門記事となります。今回はAltseedを利用しますが、UnityのようなC#やF#が扱える(.NET/Monoに対応した)ゲームエンジンであれば簡単に応用できます。また、TEAと呼ばれるModel-View構造の、モデルと更新部分を参考にしたものを使用しています。


どんなゲームに向いているか

Model-View構造自体は、パズルゲームやRPGなどゲームの本質的な処理と描画が紐付いていないものを書くのは楽になります。ただ、それに限らずどんなゲームでも使用できますし、好みの問題だと思います。また、せっかくF#を使用するなら関数型言語の強みを活かしたコードを書きたいですし、F#でオブジェクト志向はやりづらいので、個人的には気に入っています。ゲームエンジンに依存しない処理を書くことができるので、移植性も高まります。


事前知識

F#

F#Microsoftの開発している関数型言語です.C#と同様に.Net Framework上のプログラミング言語です。
ML(Meta Language)と呼ばれる言語の仲間で、OCamlに近いですがよりシンプルに記述できます。
例を見ていきましょう。
関数型言語(特にOCamlやF#といったML系)を書いたことがある方は読み飛ばしてください。

変数束縛はletを用い、関数適用に括弧は不要です。return文は存在しません。最後に書いた式の値が関数の返り値になります。

let a = 0.0f
let f x = x * x * 0.5f + 1.0f
let a = f x // f(x)でもok

引数の部分適用

let add a b = a + b
let add2 = add 2.0f
printfn "%d" (add2 10.0f) // 12.0

パイプ演算子を用いれば、関数のみでメソッドチェーンのような流れる処理が記述可能です!

// let (|>) x f = f x
let b =
  [0; 1; 2; 3; 4] // リストの区切りは';'
  |> List.map (fun x -> x + 1) // これは  |> List.map ((+) 1) と書くこともできる
  |> List.map(float32 >> add2 >> f) // 関数合成演算子。"fun x -> f(add2(float32(x)))"と同じ
  |> List.filer(fun x -> x = f(x) )

判別共用体は列挙型が強力になったようなもので、それ自身が値を持つこともできます。
例えばF#のOption型はnullを安全に扱うための仕組みで、以下のように定義されます。

type 'a Option =
  | Some of 'a
  | None

パターンマッチ*1を使って値を取り出す必要があるので、間違えてnullにアクセスしてしまうという危険性がなくなります。
再帰的な構造もシンプルに記述可能です。
参考:ホームページ移転のお知らせ - Yahoo!ジオシティーズ

// 判別共用体の宣言
type 'a Tree =
  | Nil
  | Node of 'a * 'a Tree * 'a Tree

// 再帰関数にはrecキーワードを
let rec search (x : 'a) (tree : 'a Tree) : bool =
  // パターンマッチ
  // match tree with とも書ける
  tree |> function
    | Nil -> false
    | Node(y, _, _) when x = y -> true
    | Node (y, left, _) when x < y -> search x left
    | Node (_, _, right) -> search x right

パターンマッチは非常に強力で、リスト・判別共用体・レコード型・タプルといった関数型プログラミングにおけるほとんどあらゆるデータ構造に対して、条件分岐を極めて簡潔に記述できます。
例えば以下

match list with
  | x::xs ->
    printfn "Head: %A, Tail: %A" x xs
  | [] ->
    printfn "Empty"

のように、リストがどういう構造を取っているかをシンプルに書けますし、パターンに使用した変数に値が束縛されてそのまま使用可能です。
さらにパターンの中でwhenというキーワードを使用することで、真偽値による条件分岐も簡潔に記述できます。


TEA

TEAとは、フロントエンド向けのAltJSであるプログラミング言語Elmが内蔵する、The Elm Architectureと呼ばれるモデルビュー構造です。
以下の画像のように、とてもシンプルな作りであることがわかると思います。

f:id:wraikny:20181219105334p:plain
The Elm Architecture :: Elm Workshop
copyright 2018 Sebastian Porto

  1. 情報を格納する構造体であるModelがあります。
  2. それを元に描画を行い、ユーザの操作を伝搬するためのMessageを発行します。
  3. MessageとModelを元にUpdate処理を行い、新しいModelを作成します。

これがほとんど全てです!
他にも副作用を扱うためのCmdという仕組みやキーやマウス入力などのためのSubscriptionという仕組みがあったりするのですが、今回はRuntimeに相当する部分*2を自分で書く必要があるので、F#とAltseedから使いやすいように少し変えてしまいます。

ただし今回使用するのはあくまでもTEAを参考にした構造であり、Viewの部分ではオブジェクト指向を使ったプログラミングを行っています。


Altseed

Altseedは、主に2D向けのマルチプラットフォームゲームエンジンです。
C++C#(.NET/Mono)やJava(JVM)といった複数のプログラミング言語に対応しています。
オブジェクト指向の継承をベースとした設計になっています。
シーン、レイヤー、オブジェクトという階層構造になっています。


ゲームを作る

それでは本題に入っていきます。今回は、弊サークルのにゃんにゃんがつくったぼうえいせんのパクリシンプルな防衛ゲームのようなものの基本的な部分を作りたいと思います。処理が描画と切り離されていて、マウス操作のUIも必要で、うってつけですね。


プロジェクトを作成する

Visual Studioの新しいプロジェクトから、
他の言語 -> Visual F# -> コンソールアプリケーション
を選択します。
私は

  • 名前: GoatDefense.View
  • ソリューション名: GoatDefense

としてみました。(Elm-jpのヤギにかけています。お好きに考えてみてください)

次に、ソリューションエクスプローラからソリューションを右クリックし、今度は
追加 -> 新しいプロジェクト -> Visual F# -> ライブラリ
から
GoatDefense.Core
というプロジェクトを作成します。

GoatDefense.Viewの参照設定を右クリックし、
参照の追加 -> GoatDefense.Core
にチェックを入れてOK
同様にGoatDefense.Viewの参照設定を右クリックし、
Nugetパッケージの管理 -> 参照
で検索欄にAltseedDotNetと入れて、Altseedをインストールします。


Coreの実装

基本的な型を用意する

とりあえずこんな風にファイルを用意してみました。

├── Helper
│   ├── Helper.fs
│   ├── Cmd.fs
│   └── Manager.fs
├── Model
│   └── Model.fs
├── Message
│   └── Message.fs
└── Update
    └── Update.fs
    

F#ではソリューションエクスプローラの並び方が重要で、上の位置にあるファイルのみ参照することが可能です。
Alt+上下を使用して、ファイルの位置を変えましょう。ちなみに、Visual Studio for MacではGUIでファイル順序を制御する方法がありません。エディタでfsprojファイルを直接編集しましょう。

Math.fsの詳しい説明は省きますが、二次元ベクトル型Vec2を定義しています。F#では汎用的なベクトル型を定義できるので、気になったら覗いてみてください。

それでは、順に説明していきます。
Cmd.fsとManager.fsの説明は後でします。

Model.fs

module GoatDefense.Core.Model.Model

/// ゲーム内のすべての情報を持つ
type Model =
  {
    /// ゲームが始まってからのフレーム数
    count : uint32
  }

ここにゲームの情報を格納します。
Modelはレコード型という、構造体に似た型です。
すべての情報を一箇所に集める事により、例えば通信対戦ではこのModelのみを送受信すれば良いことになりますね。
ひとまず、カウント用の変数だけ。

Message.fs

module GoatDefense.Core.Message

/// ユーザの操作の種類を更新処理に伝えるための型
type Msg =
  | NoOps

入力を伝搬するためのMsgです。前述の判別共用体です。

Update.fs

module GoatDefense.Core.Update

// open .. 省略

/// ModelのCountを1増やす。
let countUp (model : Model) : Model =
  { model with count = model.count + 1u }

/// Modelを更新する。
/// Msg毎に異なる処理を行う。
let update (msg : Msg) (model : Model) : Model * Cmd<Msg> =
  match msg with
    | Msg.NoOps ->
      let model =
        model
        |> countUp

      ( model, Cmd.Nothing )

更新処理のための関数です。とりあえずカウントアップだけ。
どういういった更新処理がおこなわれるかという情報をMsg型で受け取り、Modelを更新します。

今はModelの値がcountのみなのでわかりにくいのですが、

{ model with count = model.count + 1u }

という部分で、modelのcountだけ書き換えた新しいModel型の値を作って返しています。

副作用はCmdを経由して扱います。

現在のCmdの中身は、例として以下のように記述してあります。
汎用性のためにジェネリクスで書いています。
Cmd.fs

module GoatDefense.Core.Helper.Cmd

/// 副作用を扱うための型。
type Cmd<'Msg> =
  | Nothing
  | Random of (float -> 'Msg)

module Cmd =
  /// Cmdに副作用を込めてMsgに変換する関数。
  /// 新しいMsgが必要ないならNoneを返す。
  let toMsg<'Msg> (cmd : Cmd<'Msg>) : 'Msg option =
    match cmd with
      | Cmd.Nothing ->
        None

      | Random(msg) ->
        let rand = new System.Random()
        rand.NextDouble()
        |> msg
        |> Some

例えば、Msgを次のように書いたとしたます。

type Msg =
  | NoOps
  | RandomValue of float

そこで、もしupdateで以下のようなCmdを返してやれば、

Cmd.Random(Msg.RandomValue)

ランダムな値を持ったMsgを作ることができます!

あとはこのMsgを受け取る更新処理を記述すれば、update関数は参照透過性(数学の写像の持つ性質、すなわち、同じ引数を与えたら必ず同じ値が返ること)を保ったまま、副作用をCmdに分離して扱うことができます。

これにより、デバッグ単体テストしやすくなるという利点がありますし、もし仮にリプレイを実装するときにも、このMsgのみを記録をすればいいということになります!
ターン制のゲームであったり、あるいは上手く実装すれば、通信対戦でもこのMsgのみの送受信でつくれそうですね!
これはとても嬉しいですね。

Manager.fs

module GoatDefense.Core.Helper.Manager

open GoatDefense.Core.Helper.Cmd

[<Class>]
type Manager<'Model, 'Msg>(initialModel, updateFunc) =
  let mutable model : 'Model = initialModel
  let updateFunc : 'Msg -> 'Model -> 'Model * Cmd<'Msg> = updateFunc

  member __.Model() = model

  member __.Update(msg : 'Msg) =
    /// Cmdから'Msg optionを作って再帰的に更新を行う。
    let rec update msg model =
      let model, cmd = updateFunc msg model
      match Cmd.toMsg(cmd) with
        | Some(msg) ->
          update msg model
        | None ->
          model

    /// 受け取った'Msgを使用して更新を行う。
    model <- update msg model

Modelと 更新処理を管理するためのクラスです。
こちらもジェネリクスを使用しています。
モデルの初期値と更新用の関数を受け取っています。
mutableキーワードを使用して、modelを再代入可能にしています。
UpdateメソッドではMsgを受け取り、updateに渡します。
また、recキーワードを使用して、updateから受け取ったCmdを元に再びupdateFuncを適用して、Cmd.Nothingになるまで続けます。

Core側の基本な処理はこれだけです。
実は、Core側の本質的な部分は殆どこれだけです!
後は拡張していくだけですね!


Helperに処理を追加

Helper/Helper.fs
Helper/Alias.fs
を追加しました。
Helper.fs

[<AutoOpen>]
module GoatDefense.Core.Helper.Helper

module Seq =
  let filterMap f =
    Seq.map f
    >> Seq.filter Option.isSome
    >> Seq.map Option.get

  let tryAssoc key =
    Seq.tryFind (fst >> (=) key)
    >> Option.map snd

Seqに対して便利な関数を定義しておきます。
それぞれの型は以下のようになっています。
LinqのSelectやWhereに相当しているのがわかると思います。

Seq.map : (mapping : 'T -> 'U) -> (source : seq<'T>) -> seq<'U>
Seq.filter : (predicate : 'T -> bool) -> (source : seq<'T>) -> seq<'T>
Seq.tryFind: (predicate : 'T -> bool) -> (source : seq<'T>) -> 'T option

これらを使って、

Seq.filterMap: (mapping : 'T -> 'U option) -> (source : seq<'T>) -> seq<'U>
Seq.tryAssoc: (key: 'T) -> (source : seq<'T * 'U>) -> 'U option

といった型の関数を作っています。
filterMapは、シーケンス(C#のIEnumerableと同じ)を変換したOptionの要素のうち、Noneでないものをとりだしています。
tryAssocは、("hoge", 1)といった2要素のタプルを辞書のように見て1要素目から2要素目の値を取り出す関数です。失敗する可能性があるのでOptionで返します。
AutoOpenをつけることで、親モジュールのGoatDefense.Core.HelperをOpenすると自動でOpenされるようになります。

型のエイリアスです。さっと目を通す程度で。
Alias.fs

[<AutoOpen>]
module GoatDefense.Core.Helper.Alias

open GoatDefense.Core.Helper.Math

/// Viewと紐つけるための型。
type ID = int
module ID =
  let zero : ID = LanguagePrimitives.GenericZero
  let one : ID = LanguagePrimitives.GenericOne

/// Actorの種類を管理するための型。
type ActorType = string

/// ゲーム内で使用する距離を表す型。
type Distance = float32
module Distance =
  let zero : Distance = LanguagePrimitives.GenericZero
  let one : Distance = LanguagePrimitives.GenericOne

/// ゲーム内で使用する座標を表す型。
type Coordinate = Distance Vec2
module Coordinate =
  let zeros : Coordinate = Vec2.zeros()

/// フィールドなどの大きさを表す型。
type Size = int Vec2
module Size =
  let zeros : Size = Vec2.zeros()

/// 時間を測るための型。
type Count = int
module Count =
  let zero : Count = LanguagePrimitives.GenericZero
  let one : Count = LanguagePrimitives.GenericOne

わかりやすい名前をつけておくと良いですね。


Modelの実装

作るのは防衛ゲームです。
必要な要素をあげてみます。

  • プレイヤーの全体的な情報
  • キャラクターごとの具体的なデータ

ひとまずはこんなところでしょうか。
それでは載せていきます。

Model/Actor.fs を追加しました。
Model/Model.fs を更新しました。

Actor.fs

module GoatDefense.Core.Model.Actor

open GoatDefense.Core.Helper
open GoatDefense.Core.Helper.Math


/// キャラクタ毎の情報を持つ型。
type Actor =
  {
    actorType : ActorType
    coordinate : Coordinate
  }


  let init
    (actorType : ActorType)
    (cdn : Coordinate)
    : Actor =
    {
      actorType = actorType
      coordinate = cdn
    }

Model.fs

module GoatDefense.Core.Model.Model

// open .. 省略


/// 選択するActorの種類
type SelectedActor =
  /// 新しいActorを追加する際に保持する。
  | Add of ActorType
  /// すでにあるオブジェクトの情報を閲覧する際に保持する。
  | Info of ID


type Player =
  {
    /// IDが被らないように、次に登録するIDを記録する。
    nextID : ID
    /// 選択中のActorの情報。
    selectedActor : SelectedActor option
    /// ActorがIDと一緒に格納される
    actors : (ID * Actor) list
  }

module Player =
  let init(actors) =
    {
      nextID = actors |> List.length
      selectedActor = None
      actors = actors
    }
  /// ActorのIDを元に具体的なActorの情報を取得する。
  /// 失敗したらNoneで返す。
  let getActor (id : ID) (player : Player) : Actor option =
    player.actors
    |> Seq.tryAssoc id


/// ゲーム内のすべての情報を持つ型。
type Model =
  {
    /// ゲームが始まってからのフレーム数を表す。
    count : Count
    player : Player
  }


module Model =
  let init(player : Player) : Model =
    {
      count = Count.zero
      player = player
    }

  /// TeamとActorのIDを元に具体的なActorの情報を取得する。
  /// 失敗したらNoneで返す。
  let getActor (id : ID) (model : Model) : Actor option =
    model.player.actors
    |> Seq.tryAssoc id

ほとんどコメント内に記述しました。
重要なのは

type Actor =
  {
    actorType : ActorType
    coordinate : Coordinate
  }

でキャラクタの種類と座標の情報を格納し、
Modelで

type Player =
  {
    /// IDが被らないように、次に登録するIDを記録する。
    nextID : ID
    /// 選択中のActorの情報。
    selectedActor : SelectedActor option
    /// ActorがIDと一緒に格納される
    actors : (ID * Actor) list
  }

/// ゲーム内のすべての情報を持つ型。
type Model =
  {
    /// ゲームが始まってからのフレーム数を表す。
    count : Count
    player : Player
  }

と、PlayerはActorの情報を持ち、ModelはPlayerを持っているところです。
IDはActorを識別するための型です。
毎フレームactorsを作り直す都合上、計算量的にMap(平衡二分探索木)よりもList(Linked List)のほうが有利かと思ってMapは使っていません。
View側から参照するときは、ActorのViewがそれぞれアクセスするよりも、それを管理するクラスがListからMapを一つ作れば良さそうです。


更新処理

必要な型の定義がひとまず終わったので、更新用の処理を書いていきましょう。
まず型を書くことは、全体の見通しを立てるためにとても重要です。
Actorに必要な関数は、とりあえず

update : Model ref -> Actor -> Actor

Playerには

updateActors : Model ref -> Player -> Player
udpate : Model ref -> Player -> Player

Modelには

updatePlayer : (Player -> Player) -> Model -> Model

といった感じでしょうか。関数名と型の名前から、やりたいことはすぐに分かると思います。
コピーコストを減らすためにModel refで参照を受け取っています。
また、updatePlayerでは汎用性をもたせるために、Playerにどの関数を適用するかを引数で渡せるようにします。

それでは、実装を書いていきます。といっても、Actorの実装は今回は書く時間がないのでそのままです。
Updateモジュールの中を更にActor, Player, Modelのモジュールに分割しました。
Actorを更新する関数です。

module GoatDefense.Core.Update

// open .. 省略

module Actor =
  let move (model : Model ref) (actor : Actor) : Actor =
    actor


  let update (model : Model ref) (actor : Actor) : Actor option =
    // 今後HPを導入して、死亡していた場合はNoneを返す
    actor
    |> move model
    |> Some

今回は具体的な実装は書きません。
でも、後はmove等の実装を増やすだけです。

Playerモジュールです。

module Player =
  let updateActors (model : Model ref) (player : Player) : Player =
    // Listに対する処理を行うための関数定義。
    let f (actors : (ID * Actor) list) : (ID * Actor) list =
      actors
      // 関数を適用してNoneでない値のみ抽出する
      |> Seq.filterMap(
        fun (id, actor) ->
          Actor.update model actor
           // Noneでなかったらidとセットにする(タプルを作る)
          |> Option.map(fun actor -> id, actor)
      )
      // シーケンスからリストを構成する
      |> List.ofSeq

    // 部分的に変更した新しいModelを返す。
    { player with
        actors = player.actors |> f
    }

  let update (model : Model ref) (player : Player) : Player =
    player
    |> updateActors model

updateActors で、PlayerのすべてのActorにActor.updateを適用して更新しています。
Actorと同様に、関数を増やしてupdateの中で適用していくことで機能を増やせます。

Modelモジュールです。

module Model =
  open GoatDefense.Core.Helper.Cmd
  open GoatDefense.Core.Message


  /// ModelのCountを1増やす。
  let countUp (model : Model) : Model =
    { model with count = model.count + Count.one }


  let updatePlayer (f : Player -> Player) (model : Model) : Model =
    // 部分的に変更した新しいModelを返す。
    { model with
        player = f model.player
    }


  /// Modelを更新する。
  /// Msg毎に異なる処理を行う。
  let update (msg : Msg) (model : Model) : Model * Cmd<Msg> =
    match msg with
      | Msg.NoOps ->
        let model =
          model
          |> countUp
          |> updatePlayer (Player.update <| ref model)

        ( model, Cmd.Nothing )

Model.updateでPlayerを更新しています。
Player.updateの型はこうだったので、

Player.update : Model ref -> Player -> Player

第一引数だけ先に渡しておく部分適用を行うことで、Player -> Player 型にしてからupdatePlayerに渡しています。

次に、Actorの選択・追加・削除機能をつけていきます。

まずはPlayer

selectActor : SelectedActor option ->Player -> Player
addActor : Actor ->Player -> Player
removeActor: Player -> Player

こんな関数を定義すれば良さそうですね。
では書いてみます。

module Player =
  // 省略

  let selectActor (selected : SelectedActor option) (player : Player) : Player =
    { player with selectedActor = selected }


  let addActor (coordinate : Coordinate) (player : Player) : Player =
    player.selectedActor |> function
    | Some(Add(actorType)) ->
      let actor =
        {
          actorType = actorType
          coordinate = coordinate
        }
      
      { player with
          // IDを更新
          nextID = player.nextID + ID.one
          // :: でリストの先頭に要素を追加
          actors = (player.nextID, actor)::player.actors
      }
    
    | _ -> player

  /// Playerが選択中のActorを削除する関数。
  let removeActor
    (player : Player) : Player =
    player.selectedActor |> function
    /// 選択中
    | Some((Info(id))) ->
      /// 再帰的にリストを辿り、該当するIDを除いたリストを返す関数。
      let rec remove id
        left (list : (ID * Actor) list)
        : (ID * Actor) list =
        match list with
        // IDが一致した場合。
        | x::right when fst x = id -> left @ right

        // IDが異なる場合。
        | x::right ->
          // 再帰。
          remove id (x::left) right

        // すべての要素を精査した場合。
        | [] -> left

      { player with
          // 未選択状態に戻す
          selectedActor = None
          actors = player.actors |> remove id []
      }

    /// それ以外(任意のパターンに一致する)
    | _ -> player

書いてみました。
新しく追加した関数のみ掲載しています。

再帰関数では、リストの先頭からIDが一致するまで探し、

  • 見つかったらそれを除いたその要素の前半と後半をくっつけたリストを返します。
  • 見つからなければ要素を取り出し、後半のリストに再帰を行います。
  • リスト空になるまで走査した(みつからなかった)ら、その要素より前半のリストを返します。


Messageで入力を受け取る

Modelに適用する更新用の関数を書いてきました。しかし、これだけではこれらの関数を適用するう条件が定まっていません。
そこで、MsgがActorの選択/追加/削除を表せるようにしてみましょう。

Message.fs

module GoatDefense.Core.Message

// open .. 省略

/// ユーザの操作の種類を更新処理に伝えるための型。
type Msg =
  | NoOps
  | SelectActor of SelectedActor option
  | AddActor of Coordinate
  | RemoveActor

シンプルですね。
ofの後ろの型が、更新処理で必要な情報になってきます。
それでは、これらをUpdate.Model.updateの条件分岐で使用してみましょう。

module Model =
  // 省略

  /// Modelを更新する。
  /// Msg毎に異なる処理を行う。
  let update (msg : Msg) (model : Model) : Model * Cmd<Msg> =
    match msg with
      | Msg.NoOps ->
        let model =
          model
          |> countUp
          |> updatePlayer (Player.update <| ref model)

        ( model, Cmd.Nothing )

      | Msg.SelectActor(selected) ->
        let model =
          model
          |> updatePlayer (Player.selectActor selected)

        ( model, Cmd.Nothing )

      | Msg.AddActor(cdn) ->
        let model =
          model
          |> updatePlayer (Player.addActor cdn)

        ( model, Cmd.Nothing )

      | Msg.RemoveActor ->
        let model =
          model
          |> updatePlayer Player.removeActor

        ( model, Cmd.Nothing )

見て分かる通り、Msgに応じて適切な更新用の関数を呼び出しています。ここでも関数の部分適用を使っています。

さて、今回の記事では更新処理はここまでです!
あとはView側の、画面表示と入力の受け渡しを実装していきましょう!



Viewの実装

GoatDefense.Viewの実装に移ります。
Altseedの使い方がわからなくても、難しいコードは全く書かないので(表示用にしか使っていないため)ご安心を。
また、ここからはゲームという都合上、Elmのビューとは全く異なる実装になります。
ただ、複雑ではないので安心?してください。

最初に、リソース用のフォルダを追加しておきましょう。
実行ファイルが出力される

GoatDefense/GoatDefense.View/bin/Debug

というフォルダに Resources というフォルダを作成します。


Altseedでウィンドウを表示する

Program.fs

module GoatDefense.View.Program

[<EntryPoint>]
let main _ =
  asd.Engine.Initialize("GoatDefense", 800, 600, new asd.EngineOption())
  |> ignore

  #if DEBUG
  asd.Engine.File.AddRootDirectory("Resources")
#else
  asd.Engine.File.AddRootPackageWithPassword("Resources.pack", "password")
#endif

  while asd.Engine.DoEvents() do
    asd.Engine.Update()

  asd.Engine.Terminate()

  0

初期化、ループ、終了処理のためのAltseedのメソッドを呼び出しています。
また、ルートディレクトリを追加して、Resources以下のファイルにそのままの名前でアクセスできるようにしています。
リリース時には暗号化できるように、マクロで処理を分けています。

ここで一度、GoatDefense.Viewを実行してみましょう。
画面に黒いウィンドウが表示されれば大丈夫です。


必要な型を追加する

  • 汎用性を持った、マウスで操作可能なボタンオブジェクト Button
  • ActorTypeを画像に紐つけるための設定等を保持するレコード GameConfig
  • Actorを表示するオブジェクト ActorView
  • ActorModelに応じてActorViewをレイヤーに追加するため ActorManagerComponent
  • ゲームUIのModel, Msg, Updateを定義するモジュール GameUICore
  • ボタンや情報を表示するUIのためのレイヤー GameUILayer
  • ゲームシーン GameScene

これらのファイルを追加して以下のような構成にしてみます。
順番にはご注意を。(Alt + 上下)

├── UIElement
│   ├── IHasMsg.fs
│   ├── Button.fs
│   └── Mouse.fs
├── Game
│   ├── GameConfig.fs
│   ├── GameUILayer.fs
│   ├── ActorView.fs
│   ├── ActorManagerComponent.fs
│   └── GameScene.fs
└── Program.fs


UI要素

IHasMsg

IHasMsg.fs

[<AutoOpen>]
module GoatDefense.View.UIElement.IHasMsg

[<Interface>]
type IHasMsg<'Msg> =
  abstract Msg : 'Msg

インターフェース*3です。これをボタン等のUI要素に実装して、操作を行った時に発火するMsgを指定します。


Button

ボタンを実装します。
まずは、オブジェクトに与えるパラメータをレコード方で定義します。

[<AutoOpen>]
module GoatDefense.View.UIElement.Button

/// 形状を指定する型。
type Shape =
  | Rectangle of asd.Vector2DF
  | Circle of float32


/// Buttonにわたすためのパラメータ
type ButtonParameters =
  {
    text : string
    font : asd.Font
    backColor : asd.Color
    position : asd.Vector2DF
    shape : Shape
  }

数が多いので、こうしたほうが可読性が上がります。

次に、まずは普通の表示用のボタンです。

/// ボタンを表示するためのクラス。
[<Class>]
type Button(buttonParameters) =
  inherit asd.GeometryObject2D(
    // 形状を指定する。
    Shape = (
      buttonParameters.shape |> function
      | Rectangle(size) ->
        new asd.RectangleShape(DrawingArea = new asd.RectF(new asd.Vector2DF(0.0f, 0.0f), size))
        :> asd.Shape
      | Circle(radius) ->
        new asd.CircleShape(OuterDiameter = radius * 2.0f)
        :> asd.Shape
    ),
    Color = buttonParameters.backColor
  )

  let buttonParameters : ButtonParameters = buttonParameters

  /// テキストを子オブジェクトとして追加するメソッド。
  member private this.AddText() =
    let textObj =
      let bp = buttonParameters
      let size =
        buttonParameters.font.CalcTextureSize(
          bp.text, asd.WritingDirection.Horizontal
        )

      new asd.TextObject2D(
        Text =bp.text,
        Font = buttonParameters.font,
        Position = -size.To2DF() / 2.0f
      )

    // レイヤーの登録・オブジェクトの破棄・更新・描画を同期
    // 位置と変形をすべて同期
    // 子に親の描画優先度を加算する(親より手前に来る)
    this.AddDrawnChild(
      textObj,
      asd.ChildManagementMode.RegistrationToLayer
      ||| asd.ChildManagementMode.Disposal
      ||| asd.ChildManagementMode.IsUpdated
      ||| asd.ChildManagementMode.IsDrawn,
      asd.ChildTransformingMode.All,
      asd.ChildDrawingMode.DrawingPriority
    )

  override this.OnAdded() =
    this.AddText()

すこし複雑そうに見えますが、やっていることは形を決めてテキストを子オブジェクトとして追加しているだけです。

最後に、マウスで操作する当たり判定を持ったボタンです。
これはさっきのButtonクラスを継承したCollidableButtonクラスで実装します。

/// 衝突判のあるボタンを表示するためのクラス。
[<Class>]
type CollidableButton<'Msg>(buttonParameters, msg) =
  inherit Button(buttonParameters)

  // IHasMsgインターフェースを実装。
  interface IHasMsg<'Msg> with
    member val Msg = msg with get

  /// 形状に応じたコライダーを作成して追加するメソッド。
  member private this.AddCollider() =
    let collider =
      buttonParameters.shape |> function
      | Rectangle(size) ->
        new asd.RectangleCollider(Area = new asd.RectF(new asd.Vector2DF(0.0f, 0.0f), size))
        :> asd.Collider2D

      | Circle(radius) ->
        new asd.CircleCollider(Radius = radius)
        :> asd.Collider2D

    this.AddCollider(collider)


  override this.OnAdded() =
    base.OnAdded()

    this.AddCollider()

Colliderを形状から作成して登録しています。
OnAddedをオーバーライドする際に、base.OnAddedの実行を忘れないようにしましょう。

Mouse

CollidableButtonと衝突した状態でクリックするとButtonのMsgを使ってModelを更新するクラスを作ります。
まずはコンストラクタです。

[<AutoOpen>]
module GoatDefense.View.UIElement.Mouse

open System.Linq
open GoatDefense.Core.Helper

type MouseButton =
  | Left
  | Right

[<Class>]
// プライマリコンストラクタはinternalに制限
type CollidableMouse<'Model, 'Msg> internal (manager, camera, area, functiuons) =
  inherit asd.GeometryObject2D()

  let manager : Manager.Manager<'Model, 'Msg> = manager
  let camera : asd.CameraObject2D option = camera
  let area : asd.RectF = area

  /// 非衝突のクリックで実装するメソッドのリスト。
  let functions :
    (MouseButton *
      (Manager.Manager<'Model, 'Msg> ->
      CollidableMouse<'Model, 'Msg> -> unit)
    ) list =
    functiuons

  // マウスの位置が領域内にあるかどうかを調べる。
  let isInsideArea() : bool =
    let area =
      camera
      |> Option.map(fun c -> c.Dst.ToF())
      |> Option.defaultValue area

    let isOrderd a b c = a <= b && b <= c

    let areaRightDown = area.Position + area.Size

    let mousePos = asd.Engine.Mouse.Position

    (isOrderd area.Position.X mousePos.X areaRightDown.X ) &&
    (isOrderd area.Position.Y mousePos.Y areaRightDown.Y )

MouseButton でマウスの種類を定義します。

asd.GeometryObject2Dを継承していますが、Layerに登録する都合で、描画などは存在しません。
cameraを受け取っているのは、Layerにカメラが適用されている場合にずれてしまうMouseの位置を補正するためです。

functionsでは、マウスがボタン等のオブジェクトと非接触時にクリックした場合に実行する関数のリストを渡します。MouseButtonと合わせて、どちらのボタンが押された時に実行するかを指定します。引数として、CollidableMouseのmanagerとCollidableMouse自身をうけとります。

さらに、プライマリコンストラクタをラップした追加オブジェクトコンストラクタを定義していきます。
cameraとareaの片方の値しかいらないので、こうしています。

  // 追加オブジェクトコンストラクタ

  /// レイヤーにカメラを使用している場合のコンストラクタ
  new(manager, camera, functiuons) =
    let windowSize = asd.Engine.WindowSize.To2DF()
    new CollidableMouse<'Model, 'Msg>(
      manager, Some camera,
      new asd.RectF(new asd.Vector2DF(0.0f, 0.0f), windowSize),
      functiuons
    )

  /// カメラは使っていないがマウスの範囲を制限したい場合のコンストラクタ
  new(manager, area, functiuons) =
    let windowSize = asd.Engine.WindowSize.To2DF()
    new CollidableMouse<'Model, 'Msg>(
      manager, None, area, functiuons
    )

  /// 範囲を制限しない(画面全体)の場合のコンストラクタ
  new(manager, functiuons) =
    let windowSize = asd.Engine.WindowSize.To2DF()
    new CollidableMouse<'Model, 'Msg>(
      manager, new asd.RectF(new asd.Vector2DF(0.0f, 0.0f), windowSize), functiuons
    )

プライマリコンストラクタ自体にはinternalをつけてアクセスを制限しています。
isInsideAreaでは、マウスが特定の領域内にあるかどうかを調べます。カメラがあればその描画領域を、そうでなければ指定した領域を用います。

それでは、メソッドを順に見ていきます。

// 省略
  // マウスの位置に合わせてオブジェクトを動かすためのメソッド。
  member private this.SetPosition() =
    // マウスの座標
    let mousePos = asd.Engine.Mouse.Position

    camera |> function
    | Some(camera) ->
      let src = camera.Src.ToF()
      let dst = camera.Dst.ToF()
      // カメラを使っているときは座標に補正をかける。
      let position = (mousePos - dst.Position) * src.Size / dst.Size + src.Position
      this.Position <- position

    | None ->
      this.Position <- mousePos

これは、自身の位置をマウスに追従させて動かすためのメソッドです。ただしレイヤーにカメラが適用されているときは描画位置とマウスの位置がズレる可能性があるので、それを補正します。

// 省略

  /// 衝突状態にある'Msgをすべて取得
  member private this.GetMessages() : 'Msg list =
    // OfTypeをするためにLinqを用いている。

    // 衝突情報の一覧
    this.Collisions2DInfo
      // 自身が衝突したものを抽出
      .Where(fun x -> x.SelfCollider.OwnerObject.Equals(this))
      // 保持しているオブジェクトに変換
      .Select(fun x -> x.TheirsCollider.OwnerObject)
      // 描画されているオブジェクトを抽出
      .Where(fun x -> x.IsDrawn)
      // インターフェースを実装しているものを抽出
      .OfType<IHasMsg<'Msg>>()
      // Msgに変換
      .Select(fun x -> x.Msg)

    |> Seq.toList

衝突情報を元に発火するMsgを取得するメソッドです。
OfTypeメソッドを使用したかったので、F#の高階関数ではなくSystem.Linqを使用しています。
具体的には以下の手順で処理を行っています。

  1. 衝突情報の一覧から、
  2. 自身と衝突したコライダーのうち、
  3. それを保持しているオブジェクトが
  4. 描画されていて、
  5. IHasMsg<'Msg>を実装しているものなら、
  6. Msgを取得

これで'Msgのリストを得ることができます。

次は、今のメソッドを使用して取得したリストを元にMsgを発火するメソッドです。

// 省略

  // Msgのリストの先頭要素があればModelを更新する。
  member private this.FireMsg() =
    // エイリアス
    let isReleased = (=) asd.MouseButtonState.Release
    let left = isReleased asd.Engine.Mouse.LeftButton.ButtonState
    let right = isReleased asd.Engine.Mouse.RightButton.ButtonState

    let funcsWithButton button =
      let functions = functions |> List.filter (fst >> (=) button)
      for _, func in functions do
          func manager this

    // 左右のボタンの押し状態
    (left, right)
    |> function
    // 左のボタンが押されている
    | true, _ ->
      this.GetMessages() |> function
      // 発火しうるMsgが存在する場合
      | msg::_ ->
        // 更新
        manager.Update(msg)

        #if DEBUG
        // デバッグ用文字出力
        printfn "%A is Fired!" msg
        #endif

      // 存在しない場合。
      | _ ->
        funcsWithButton Left

    // 右のボタンのみ押されている。
    | false, true -> funcsWithButton Right

    | _ -> ()

Mouseの左右のボタンの押し状態がReleaseに一致するかをパターンマッチで取り出します。それぞれコメントのとおりです。
GetMessagesメソッドの結果のリストのパターンマッチでは、要素があれば衝突したMsgを発火、そうでなければfunctionsの関数を実行します。

最後にオーバーライドしたメソッドでは、以上のメソッドを呼び出しています。

// 省略

  // コライダを定義
  member val private Collider = new asd.CircleCollider( Radius = 5.0f )

  override this.OnAdded() =
    // コライダを追加
    this.AddCollider(this.Collider)


  override this.OnUpdate() =
    let inside = isInsideArea()

    // 範囲内にある場合のみ更新を行う。
    if inside then
      this.SetPosition()

      this.FireMsg()

    // Colliderの表示状態切替
    this.Collider.IsVisible <- inside


GameConfig

設定などを格納します。

[<AutoOpen>]
module GoatDefense.View.Game.GameConfig

open GoatDefense.Core.Helper

type ActorConfig =
  {
    actorType : ActorType
    filename : string
  }

type GameConfig =
  {
    /// 描画領域のマージン
    drawAreaMargin : int
    actorConfigMap : Map<ActorType, ActorConfig>
    actorNotFoundImage : string
  }

module GameConfig =
  let init(drawAreaMargin, actorConfigs : ActorConfig list, actorNotFound) =
    {
      drawAreaMargin = drawAreaMargin
      actorConfigMap =
        actorConfigs
        |> List.map(fun ac -> (ac.actorType, ac))
        |> Map.ofList

      actorNotFoundImage = actorNotFound
    }

  /// ActorTypeからfilenameを取得する関数
  let getActorFilename (ac : ActorType) (gameConfig : GameConfig) =
    gameConfig.actorConfigMap
    |> Map.tryFind ac
    |> Option.map(fun x -> x.filename)
    |> Option.defaultValue(gameConfig.actorNotFoundImage)

  // ゲーム画面の描画領域を計算する関数。
  let getGameDrawingArea (gameConfig : GameConfig) : asd.RectI =
    let margin = gameConfig.drawAreaMargin
    let size = asd.Engine.WindowSize.Y - margin * 2
    new asd.RectI(margin, margin, size, size)

  // UI画面の描画領域を計算する関数。
  let getUIDrawingArea (gameConfig : GameConfig) : asd.RectI =
    let margin = gameConfig.drawAreaMargin
    let windowSize = asd.Engine.WindowSize
    let height = windowSize.Y
    let width = windowSize.X - height
    new asd.RectI(height, margin, width - margin, height - 2 * margin)

ActorTypeから画像のfilenameに対応付けるための型です。
initでは、リストを受け取ってMapに変換しています。
画像が見つからなかった場合のファイル名を入れておきます。
ゲーム画面とUI画面の描画領域を計算する関数を宣言しておきます。


GameUI

GameUILayer

ここでは、

  • 選択したオブジェクトの情報を表示するテキスト、
  • プレイヤが操作するためのボタン

を表示したいです。
asd.Layer2Dを継承したクラスを作ります。

また、モジュールを上方で定義して使用していますが、見た目を整えるためなので今回は省略させてください(量が多いので)。やっていることは、テキストやボタンのオブジェクトのリストから、サイズを利用して位置をずらして配置する、といった感じです。メニューの種類(タブ)毎にasd.GeometryObjectの子オブジェクトとして持つことで、移動や表示・更新を一括で変更できるようにしています。

まずはコンストラクタ前半です。

// 省略

[<Class>]
type GameUILayer(gameConfig, manager) =
  inherit asd.Layer2D()

  // 設定
  let gameConfig : GameConfig = gameConfig

  // ゲームのModelを管理するクラス。
  let gameManager : Manager<Model.Model, Msg> = manager

  /// GameUIの表示領域
  let drawnArea = GameConfig.getUIDrawingArea gameConfig

  /// UIオブジェクトの位置の指定。
  let uiObjectPos =
    drawnArea.Position.To2DF() +
    new asd.Vector2DF(drawnArea.Size.To2DF().X * 0.1f, 15.0f)

今まで見てきたものと変わらないですね。表示位置の指定に使う値を計算しておきました。

ここから少しややこしくなります。

// 省略

  /// Actorを選択した時に表示する情報
  let actorInfo =
    let obj =
      new GameUITab.ActorInfo(gameConfig)
    obj.Position <- uiObjectPos
    obj


  let gameUITabs : (Model.UIMode * asd.GeometryObject2D) list =
    /// メニュー
    let menuObj =
      let obj =
        GameUITab.menuObj(gameConfig)
      obj.Position <- uiObjectPos
      obj


    /// 追加可能なActorの一覧
    let actorConfigList =
      let obj =
        GameUITab.actorConfigList(gameConfig)
      obj.Position <- uiObjectPos

      obj
    
    [
      Model.Menu, menuObj
      Model.AddList, actorConfigList
      Model.ActorInfo, actorInfo :> asd.GeometryObject2D
    ]

  /// Objectの表示・更新状態を変更する。
  let changeObjectState (cond) (obj : asd.Object2D) =
    obj.IsDrawn <- cond
    obj.IsUpdated <- cond

  /// 現在のモードから表示するオブジェクトを選択する。
  let DrawTab name =
    for n, t in gameUITabs do
      changeObjectState (n = name) t

      if n = name then
        t.Position <- uiObjectPos

actorInfo は省略したモジュールデ定義してあるクラスのインスタンスで、asd.GeometryObjectを継承しています。メニューやリストは値の変更等はありませんが、Actorを選択して表示するにはそれぞれの文字や画像毎に変更を行う必要があるため、クラスを宣言しています。
中身が気になったらGithubの方をご覧ください。

では、オーバーライドしたメソッドを見ていきます。


  override this.OnAdded() =
    let mouse =
      new CollidableMouse<Model.Model, Msg>(
        gameManager, drawnArea.ToF(), []
      )

    this.AddObject(mouse)

    for _, t in gameUITabs do
      this.AddObject(t)
      changeObjectState false t

    this.AddObject(board)


  override this.OnUpdated() =
    let player = gameManager.Model().player
    
    DrawTab player.uiMode

    player.selectedActor |> function
    | Some(Model.Info(id)) ->
      player.actors
      |> Seq.tryAssoc id
      |> function
      | Some(actor) ->
        actorInfo.UpdateInfo(id, actor)
      | None -> ()

    | Some(Model.Add(ac)) -> ()
    | None -> ()

OnAddedではオブジェクトをレイヤーに追加しているだけです。また、さっき説明したCollidableMouseをつかってボタンを押せるようにしています。
OnUpdateでは、Actorを選択中にリアルタイムに値を反映させるために、actorInfoのUpdateInfoメソッドを呼んでいます。

また、Model.Menuといった見慣れない型がありますね。Core側のModelに、GameUIがどの画面を開いているかということを記録するための型とフィールドを持たせました。見ていきましょう。


CoreにUIModeを追加

Model.fs

type UIMode =
  | Menu
  | AddList
  | ActorInfo


type Player =
  {
    nextID : ID
    selectedActor : SelectedActor option
    actors : (ID * Actor) list
    uiMode : UIMode
  }

プレイヤに持たせています。説明する必要はなさそうですね。

これに伴い、uiModeを変換するMsgと更新処理も加えています。こちらも極めて簡単な変更のみです。
Message.fs

type Msg =
  | NoOps
  | SelectActor of SelectedActor option
  | AddActor of Coordinate
  | RemoveActor
  | ChageUIMode of UIMode

Msgはひと目見てどのような操作を受け付けるのかわかるので、変更もしやすいですしわかりやすいですね。

Update.fs

// 省略
module Player =
 // 省略

  let updateUIMode (uiMode : UIMode) (player : Player) =
    { player with uiMode = uiMode }

module Model =
  // 省略
  let update (msg : Msg) (model : Model) : Model * Cmd<Msg> =
    // 省略
      | Msg.SelectActor(selected) ->
        let model =
          model
          |> updatePlayer (Player.selectActor selected)
          |> updatePlayer (
            selected |> function
            | Some(Info _) -> ActorInfo
            | Some(Add _) -> AddList
            | None -> Menu
            |> Player.updateUIMode
          )
    // 省略
      | Msg.ChageUIMode(mode) ->
        let model =
          { model with
              player =
                { model.player with
                    uiMode = mode
                }
          }

        ( model, Cmd.Nothing )

まず、PlayerモジュールにuiModeを更新する関数を定義して、それをModelから呼び出しています。
まず下の方、ChangeUIModeは見たままで、Playerのmodeを受け取ったmodeに変換しているだけですね。
気になるのはSelctActorの方ですが、これは

  • Actorを選択したらActorInfoに遷移
  • Actorで追加するActorTypeを選択したらそのままAddList
  • 非選択状態になったらメニューに戻る

いという更新をしているだけです。
UILayerは一部省略してしまいましたが、例えばテキストとボタンを適当に配置した場合でも当たり判定をつけているためボタンは押せるので、省略範囲はあくまで見た目の問題で、需要な部分は説明できたと思います。

では、次の項に移っていきます。


Actorの描画に関するクラス

ActorView

asd.TextureObject2Dを継承します。
textureなどはコンストラクタで受け取り初期化します。
また、対応するIDと、マウスで選択するためのColliderを追加します。

[<AutoOpen>]
module GoatDefense.View.Game.ActorView

// open .. 省略

[<Class>]
type ActorView(id, actor : Actor.Actor, gameConfig) as this =
  inherit asd.TextureObject2D ()
  // コンストラクタ
  do
    // filenameを取得する
    let filename =
      GameConfig.getActorFilename actor.actorType (!gameConfig)

    // 各プロパティをセットする。
    this.Texture <- asd.Engine.Graphics.CreateTexture2D(filename)
    this.CenterPosition <- this.Texture.Size.To2DF() / 2.0f
    this.Position <-
      let cdn = actor.coordinate
      new asd.Vector2DF(cdn.x, cdn.y)


  // マウスでクリックするとオブジェクトを選択できるようにする。
  // こうすることで、CollidableButtonに対する処理と同一に記述できる。
  interface IHasMsg<Msg> with
    member val Msg = Msg.SelectActor(Some <| Model.Info id) with get


  /// オブジェクトの位置を更新するためのメソッド。
  member this.UpdatePosition(actor : Actor.Actor) =
    let cdn = actor.coordinate
    let pos = this.Position

    if Math.Vec2.init(pos.X, pos.Y) <> cdn then
      this.Position <- new asd.Vector2DF(cdn.x, cdn.y)


  override this.OnAdded() =
    // コライダを作る。
    let collider =
      new asd.RectangleCollider(
        Area =
          let size = this.Texture.Size.To2DF()
          in
          new asd.RectF(-size / 2.0f, size)
      )
    
    // オブジェクトにコライダを登録する。
    this.AddCollider(collider)

コンストラクタはこのように、do以降に書きます。asキーワードで、thisを使って自身を参照することができます。
ここでは、actorとgameConfigから対応するfilenameを取得し、テクスチャを読み込んでいます。
プロパティとしてIDをもたせて、OnAddedでコライダーを作成して追加しています。


ActorManagerComponent

asd.Layer2DComponentを継承してLayerに登録します。
このクラスにはManagerへの参照をもたせ、ModelにアクセスをしてActorVeiwを追加・更新・削除する機能を持ちます。

まずはlet束縛とフィールドを見ていきましょう。

[<AutoOpen>]
module GoatDefense.View.Game.ActorManagerComponent

open System.Collections.Generic

// open .. 省略

[<Class>]
type ActorManagerComponent(gameConfig, manager) =
  inherit asd.Layer2DComponent()

  // 設定
  let gameConfig : GameConfig = gameConfig

  /// Managerクラスへの参照。
  let manager : Manager.Manager<Model.Model, Message.Msg> = manager

  /// Playerを取得する関数
  let getPlayer() = manager.Model().player

  /// Actorが追加されたか比較するための変数。
  let mutable nextID : ID = getPlayer().nextID

  /// IDとActorのViewへの参照のペアを保持する
  member val private ActorsViewList = new List<ID * ActorView>()

gameConfig、managerにコンストラクタで受け取った値を束縛して、以降でアクセスできるようにしています。
nextIDはmodel.player.nextIDと比較して、ModelのPlayerにActorが新しく追加されているかを調べるためのものです。mutableキーワードをつけて変更可能にしています。

ActorsViewList*4はIDとActorのViewを対応付けるListです。これはF#ではなく、C#などで主に利用する System.Collections.Generic のリストで、AddやRemoveといった操作が行なえます。

次に、Actorの追加を行うメソッドを見ていきます。

// 省略

  /// ActorのViewを追加するメソッド。
  member private this.AddActorsViewList(actorsMap : Map<ID, Actor.Actor> ref) =
    // a..bはaとbを含むので、-1する
    for id in nextID..(getPlayer().nextID - ID.one) do
      !actorsMap
      |> Map.tryFind id
      |> function
      | Some(actor) ->
        let obj = new ActorView(id, actor, ref gameConfig)

        // ObjectのListに追加。
        this.ActorsViewList.Add( (id, obj) )
        // Layerに追加
        this.Owner.AddObject obj

      | None -> ()

    // 自身のnextIDを更新
    nextID <- getPlayer().nextID

引数のactorsMapは、コピーコストを抑えるために参照で渡しています。

nextIDの差分だけ、forを使ってオブジェクトを追加していきます。

asd.Layer2DComponentはOwnerで自身が登録されたLayerにアクセスできるので、それをつかってObjectをLayerに登録します。
また、IDと対応つけるために前述のリストにも登録します。

Actorの更新・削除を行うメソッドです。

// 省略

  /// ActorViewを更新するメソッド。
  member private this.UpdateActorsView(actorsMap : Map<ID, Actor.Actor> ref) =
    // ActorsViewListを複製する。
    let objects = new List<ID * ActorView>(this.ActorsViewList)

    for (id, obj) in objects do
      !actorsMap
      |> Map.tryFind id
      |> function
      // ModelにIDが存在するとき
      | Some(actor) ->
        obj.UpdatePosition(actor)

      // ModelにIDが見つからないとき
      | None ->
        this.ActorsViewList.Remove( (id, obj) ) |> ignore
        obj.Dispose()

これもコメントに書いてあるとおりなのですが、IDでModelを検索して差分があれば更新、見つからなければ削除、という感じです。
ActorsViewListをforで回している間にRemoveメソッドを実行するのは良くないので、一度リストを複製しています。
ActorViewのUpdatePositionメソッドを呼び出します。

最後に、asd.Layer2DComponentのOnLayerUpdatedメソッドをオーバーライドして、毎フレーム実行されるようにします。

// 省略

  override this.OnLayerUpdated() =
    // Modelのactorsを辞書に変換
    let actorsMap = getPlayer().actors |> Map.ofList

    // nextIDが異なるとき、オブジェクトの追加処理。
    if nextID <> getPlayer().nextID then
      this.AddActorsViewList(ref actorsMap)

    // 更新処理
    this.UpdateActorsView(ref actorsMap)

Mapの作成コストを抑えるために、ここでListから変換して各メソッドに参照を渡します。

Actorの描画はこれで終わりです。


GameScene

必要な要素を列挙します。

  • GameConfig
  • Manager
  • 背景のLayer
  • Actorを登録するLayer
  • 情報を表示するLayer

とりあえず、ざっと書いたコードを載せてみます。

まずはコンストラク

module GoatDefense.View.Game.GameScene

// open .. 省略

[<Class>]
type GameScene() =
  inherit asd.Scene()

  /// 設定
  let gameConfig =
    GameConfig.init(
      10,
      [
        {
          actorType = "WhiteGoat"
          filename = "Images/Actor/WhiteGoat.png"
          texSize = 100
        }
        {
          actorType = "BlackGoat"
          filename = "Images/Actor/BlackGoat.png"
          texSize = 100
        }
      ],
      "Images/Actor/NotFound.png"
    )
 
  /// Model管理
  let manager =
    let model =
      let player = Model.Player.init([])
      Model.Model.init(player)

    new Manager<Model.Model, Msg>(model, Model.update)

ここでは参照をうけとるのではなく、現状はGameSceneがインスタンス等を作っています。
Select画面を後々実装するなら、そこから受け取ればよいですえ。

次に、
OnRegisteredです
ここではLayerやカメラの宣言を行います。

  override this.OnRegistered() =
    /// 背景のレイヤー。
    let backLayer =
      let layer = new asd.Layer2D()

      let windowSize = asd.Engine.WindowSize

      // 図形オブジェクト
      let gameBackColor : asd.GeometryObject2D =
        // 省略

      // 図形オブジェクト
      let uiBackColor : asd.GeometryObject2D =
        // 省略

      layer.AddObject(gameBackColor)
      layer.AddObject(uiBackColor)

      layer

    /// ActorViewを追加するためのレイヤー。
    let actorsLayer =
      let layer = new asd.Layer2D()

      // コンポーネントを追加する。
      let actorManager = new ActorManagerComponent(gameConfig, manager)
      layer.AddComponent(actorManager, "ActorManager")

      /// 描画領域を限定するためのカメラ
      let camera =
        let drawArea = GameConfig.getGameDrawingArea gameConfig

        new asd.CameraObject2D(
          Src = drawArea, // 描画元
          Dst = drawArea // 描画先
        )

      layer.AddObject(camera)

      let mouse =
        new CollidableMouse<Model.Model, Msg>(
          manager, camera,
          [
            // ModelのSelectedActorが追加状態の時、左クリックでAddActorを発火する。
            MouseButton.Left,
            (fun
              (m : Manager<Model.Model, Msg>)
              (cm : CollidableMouse<Model.Model, Msg>) ->
              m.Model().player.selectedActor
              |> function
              | Some (Model.SelectedActor.Add(_)) ->
                let cdn = Math.Vec2.init(cm.Position.X, cm.Position.Y)
                manager.Update(Msg.AddActor(cdn))
              | _ -> ()
            )

            // Modelが選択状態の時、右クリックで選択状態を解除する。
            MouseButton.Right,
            (fun (m : Manager<Model.Model, Msg>) _ ->
              manager.Update(Msg.SelectActor(None))
            )

          ]
        )

      layer.AddObject(mouse)

      layer

    /// ボタンや情報を表示するためのレイヤー。
    let uiLayer = new GameUILayer.GameUILayer(gameConfig, manager)


    let layers =
      [
        backLayer
        actorsLayer
        uiLayer :> asd.Layer2D
      ]

    for layer in layers do
      // レイヤーを追加する。
      this.AddLayer(layer)

一部省略しました。

  • backLayer
  • actorsLayer
  • uiLayer

等3つのレイヤーを宣言してシーンに登録しています。また、actorsListに追加されたMouseの役割は接触時に左クリックで選択することですが、それ以外にメソッドを渡して条件に応じて発火するようにしています。

最後はOnUpdatedです

  override this.OnUpdated() =
    // Modelの更新を行う。
    manager.Update(Msg.NoOps)

ゲームのメインの更新処理になります。

これで今回はひとまず完成となります!

終わりに。

42000字以上ある記事を読んでいただいで(読んでいただけたのか?)感謝します!!!
基礎的な部分とは言いましたが、ビュー側は少しややこしいですが、モデルや更新処理の拡張はとてもしやすい作りになっているので、後少し頑張ればゲームが完成する勢いです!

実は去年のACACは、ゲームの方がなかなか完成せずに記事をかけなかったので、今年は記事をかけて嬉しく思っていますが、まさかここまでの量になるとは全く思っていませんでしたが……

また、今まではAmusement Creatorsでコミケにて作品を出展していましたが、C95からは個人サークルLepus Pluviaでも作品を出展してきます! 今回は過去作メインですが、新作のほうも頑張って開発していきます! 応援していただけたら嬉しいです! 

Githubもう一度載せておきます!
一応ブランチを分けて保存しておきます。
github.com

*1:switch文を強くしたような式、網羅性がないと警告が出る

*2:MVCで言うControllerなのかな?

*3:インターフェースを知らない人向けに説明すると、具体的な内容は問わずに型の制約を満たしたプロパティやメソッドの実装を保証するという説明でいいでしょうか。詳しくは インターフェース - C# によるプログラミング入門 | ++C++; // 未確認飛行 C

*4:ActorsViewListもlet束縛で問題無いのですが、F#の型ではないのでなんとなくフィールドにしてみました。この辺詳しい人いたら教えてもらいたいです。