Closed9

PureScriptでゲームが簡単に作れるライブラリを作るぞ

ゆきくらげゆきくらげ

現状

canvas描画を簡単に行うライブラリとなっている.ゲームを開始するにはrunGameM関数にGameSpecM型の値をぶちこむ

newtype GameSpecM sprite gameState input output = GameSpecM
  { fps :: Int
  , canvasSpec :: 
    { height :: Number
    , width :: Number
    }
  , sprites :: Array (sprite /\ String)
  , initGameState :: gameState
  , render :: GlappleM gameState output (Picture sprite)
  , handler :: Event input -> GlappleM gameState output Unit
  }

GameSpecM型の値を作るには次の値が必要である

  • fps
    画面のfps
  • canvasSpec
    画面の大きさ
  • sprites
    画像を先読み込みする
    ex: [Apple /\ "images/apple.png", Banana /\ "images/banana.png"]
    これをすることで後述するPicturesprite関数によって画像を表示出来る
  • initGameState
    ゲームの初期状態.任意の型を状態として持てるgameStateで多相化されている.
  • render
    ゲーム画面を表示する毎フレームの処理
  • handler
    様々なイベントを処理する

render, handlerがわかりにくいので詳しく

render

GlappleM gameState outputはモナドであって,中で使えるのはliftEffectによる副作用のある処理,現在のゲーム状態を取得するgetGameState,現在のゲーム状態を更新するputGameState,およびmodifyGameState,ゲーム開始からの時間を取得するgetTotalTimeである.
またPicture spriteはキャンバスに描く図形を表す.
empty: 空の図形
sprite: spritesで読み込んだ画像を描画
text: 文字列を描画
polygon: 多角形を描画
etc...
などでPicture sprite型の値を作れる.

例えば,回転するりんごを例にとる

rotatingApple :: forall o. GlappleM GameState o (Picture Sprite)
rotatingApple = do
  timeMaybe <- getTotalTime
  let
    time = case timeMaybe of
      Just (Milliseconds x) -> x
      Nothing -> 0.0
  pure $ rotate (2.0 * pi * time / 200.0) $ sprite Apple

getTotalTimeから得られる値はMaybeでくくられたMillisecondsの値.これはゲーム開始前のプリロードの段階で呼ばれたとき,Nothingを返すため(ゲーム開始時からの時間を得るからゲーム開始前は定義されてない(普通に0.0返してもいいかも知れない,わからず))
でなんやかんやしてtimegetTotalTimeの値をMaybeを引き剥がし数値にしてぶちこむ.
sprite Appleでりんごの画像を描画.それをrotate2.0 * pi * time / 200.0回転,という流れ
これをGameSpecMrenderに指定してやれば動く

handler

Event様々なイベントを表す型.

data KeyState = KeyDown | KeyUp
data Event input = KeyEvent String KeyState | Update Milliseconds | Input input

KeyEventについてはわかりやすい.キーが押されたときに呼び出される.例えば"w"キーが押された時に内部状態を更新するハンドラはこう書ける

handler (KeyEvent "w" KeyDown) = {何かしらの処理}
handler _ = pure unit

Updateは毎フレーム描画の後に呼び出されるもの.
Millisecondsは前回Updateからの経過時間を格納している.
わかりにくいのはInput これは別項目で

runGameM関数

runGameM
  :: forall sprite gameState input output
   . Ord sprite
  => GameSpecM sprite gameState input output
  -> CanvasElement
  -> (output -> Effect Unit)
  -> Effect (GameId gameState input output)

runGameにはGameSpec型の値,キャンバス要素,そしてOutputハンドラ(後述)を打ち込めば勝手にゲームが始まる.そしてGameId型の値が帰ってくる(後述)

inputoutput

GameSpecMの定義をみるとinputoutputが多相化されている.これについて.

Input

ゲームを開始した後も,キャンバス外からのアクションをゲームに伝えたいときがある.
ex. キャンバス要素とは別にボタン要素を作って,そのボタンを押すとセーブが出来る,など.
この際使うのがInputの仕組みで,通信に用いる型がinputである.また通信を発火させる関数はtellである.次のような感じで使える.

# ゲーム側
data Input = SomeInput
handler (Input SomeInput) = {- 何かしらの処理 -}
...

# 外部
gameId <- runGameM gameSpec canvas outputHandler
tell gameId SomeInput --ここでゲームと通信

tellに関数にgameIdをぶち込むと通信が出来る.わかりやすい.

Output

逆にゲームからの何かしらの出力を処理したいときがある.そのとき使うのがOutputの仕組み.通信を発火させる関数はraise

# ゲーム側
data Output = SomeOutput
handler {- 何かしら -} =  do
  ...
  raise SomeOutput --ここで外部と通信
  ...

# 外部
outputHandler SomeOutput = {- 何かしらの処理 -}
gameId <- runGameM gameSpec canvas outputHandler --ここでハンドラを渡す.

ゲームからSomeOutputが渡されるたび{- 何かしらの処理 -}を実行する

以上.

ゆきくらげゆきくらげ

今後

GameState1つのみでゲームを管理するのはこころもとない.(Webページのコンポーネント分けのように,ゲームの状態も細かく分割管理されるべき)
ここの仕組みを作りたい.

ゆきくらげゆきくらげ

GameSpecCという、レンダーとハンドラを多相化して色々受け付けるやつ作ってみたけどあまり便利じゃなさそう

ゆきくらげゆきくらげ

コンポーネントシステムができた

GameIdはゲームそのもの表すような型であるとして,renderGameという関数でGameIdPictureに変換できるようにした.これで生成したゲームのGameIdを他のゲームのrender内で使うことが出来るので,階層化出来る
ゲームの生成場所は親ゲームのGlappleM
もちろんGameIdを使って通信もで

今後

一度作成したゲームを破棄出来るようにしたい

ゆきくらげゆきくらげ

destroy関数で子ゲームを破棄,destroyMeで自分自身を破棄出来るようにした
しかし今後一切更新をしなくなるという意味であって,メモリ上から破棄されるというわけではない.
それぞれのゲームの実態はただの関数なので問題は無いかもしれない

ゆきくらげゆきくらげ

今後

イベントを充実させる(現在キーボード入力しか見ていない)
経過秒数がMilliSecondで帰ってくるのウザイので変える

ゆきくらげゆきくらげ

バグが治った
詳細↓
https://zenn.dev/yukikurage/articles/ab4988e5f7ebca
でも高階コンポーネントのノリで高階ゲームを作ろうとしたら詰んだ

詰んだ

原因はInputの型が直和のような形である事が好ましいから。例えば子ゲームのInputの型が

data Input = Hoge Int | Fuga Int

であったならば親ゲームは子ゲームにInput通信をするとき、HogeFugaから一つ選んで渡します。
しかしレコード型

type Input = {hoge :: Int, fuga :: Int}

のような形であるときはhogeとfuga両方に値を入れなければなりません。これはInputとして扱いづらいです
ところで高階ゲームを作るときは取れる子ゲームを型で絞り込みできます たとえば内部状態として

forall r. {foo :: Int | r}

という型をもつ子ゲームしか取れないとすればfooという内部状態を持つゲームなら全部取れるようになって便利です 
こういう類の多相化はレコード型の方が便利ですが、前述の通りInputはレコード型であると意味を成しません 從って高階コンポーネントでInputの拡張というものが難しくなってしまいました。
row typeを上手く使えば解決できそう
https://pursuit.purescript.org/packages/purescript-typelevel-prelude/6.0.0/docs/Type.Row

このスクラップは2022/01/16にクローズされました