bitterharvest’s diary

A Bitter Harvestは小説の題名。作者は豪州のPeter Yeldham。苦闘の末に勝ちえた偏見からの解放は命との引換になったという悲しい物語

YampaとOpenGL(GLUT)で簡単なシューティングゲームを作成する―オブジェクトの運命

1.概略

これまで説明してきたシューティングゲームでは、鳥のオブジェクトが一つ、石のオブジェクトが二つ存在した。この記事では、これらのオブジェクトがどのような運命をたどるかを説明する。

オブジェクトの振舞いは信号関数によって制御される。例えば、速度\(v\)で時間\(t\)の間に移動した距離\(y\)は、式
\begin{eqnarray}
y_t & = & y_0 + \int_0^t v dt
\end{eqnarray}
で表していた。

従って、オブジェクトの状態は信号関数の始まりの時間\(t=0\)と現在の時間\(t=t\)から得るようになっている。

一方、ゲームは下図に基づいて実行されていた。
f:id:bitterharvest:20141102101832p:plain
長いゲームの時間は、プログラムの中で決められる単位の時間(時間間隔、main.hsの関数idleの中にあるdt)を一つのサイクルとして、オブジェクトの振舞いを求めていた。即ち、サイクルの始まりの時間でのオブジェクトの状態に基づいて、サイクルの終わりの時間での状態を求めていた。サイクルの終わりの時間は現在の時間\(t=t\)であるが、サイクルの始まりの時間は必ずしも信号関数の始まりの時間\(t=0\)と一致しない。このことが原因で、Yampaでプログラムを書いているときに混乱を招くことがあるが、信号関数の始まりの時間に注意を払えばこの問題は解消される。それでは、このことを念頭に、オブジェクトがどのような運命をたどるのかを説明する。

2.オブジェクトの誕生

オブジェクトが誕生するのは、一番最初のサイクルで、mainSFが呼ばれた時である。即ち、次のプログラムである。

mainSF :: SF (Event Input) (IO ())
mainSF = parseInput >>> process objs >>^  \ oos -> draw oos
  where
    stone1Obj = stoneObject (-8, 0)  (1, 15)
    stone2Obj = stoneObject (-8, 0)  (4, 17)
    birdObj   = birdObject  (-8, 10) (6, 0)
    objs      = listToIL [stone1Obj, stone2Obj, birdObj]

このプログラムによって、石はstone1Objとstone2Objで、鳥はbirdObjで呼ばれる。石は二つともstoneObjectという関数で、鳥はbirdObjectという関数でその振舞いが定義されている。

また、listToILによって、stone1Obj, stone2Obj, birdObjは識別リストに登録され、識別番号が与えられる。識別リストに登録されているときはゲームでの登場者であるが、識別リストから外されるとゲームでの登場者でなくなる。

関数stoneObjectは石の振舞いを与える。

stoneObject :: Pos -> Vel -> Object
stoneObject pos0 vel0 = proc input -> do
  rec
    (pos, vel) <- stayingBall pos0 vel0 -< input
  returnA -< defaultObjOutput{ooKind = Stone, ooState = State {position' = pos, velocity = vel}}

この関数は、位置pos0と速度vel0を入力として受け取り、オブジェクトdefaultObjOutputを送出する(詳細に説明すると、位置pos0と速度vel0の他に外部から入力inputも受け取る。これらを信号関数stayingBallに適用して、このサイクルが終了したときの石の状態(pos, vel)を得て、これらの状態を保持するdefaultObjOutputを返す)。このdefaultObjOutputは、石の最新状態(pos, vel)を有している。そして、defaultObjOutputは次の入力inputの一部となる。

stone1Objの場合には、初期状態はpos0=(-8, 0)で、vel0=(1, 15)である。従って、最初のサイクルが始まるときのこの石の状態は{pos0=(-8, 0),vel0=(1, 15)}である。最初のサイクルが終わった時の状態は{pos,vel}であるが、その詳細については、次に詳しく述べる。

3.発射台の移動

最初のサイクルを迎えたときのstone1Objの振舞いを見ることとする。先に説明したように、stone1Objは、stoneObjectと呼ばれる関数である。この関数は、信号関数stayingBallを実行する。もちろん、信号関数の始まりの時間は、最初のサイクルの始まりの時間である。stayingBallは次のようになっている。

stayingBall :: Pos -> Vel -> SF ObjEvents (Pos, Vel)
stayingBall pos0 vel0  = switch (flyaway pos0 vel0) (\ (pos, vel, no) -> if no == 3 then bouncingBall pos vel else stayingBall pos vel)
  where 
    flyaway :: Pos -> Vel -> SF ObjEvents ((Pos, Vel), Event(Pos, Vel, Integer))
    flyaway pos0' vel0' = proc input -> do
      rec
        evt1 <- edge -< isEvent (rightEvs (oeInput input))
        evt2 <- edge -< isEvent (leftEvs (oeInput input))
        evt3 <- edge -< isEvent (upEvs (oeInput input))
      returnA -< ((((fst pos0') + dCount (oeInput input) - aCount (oeInput input), snd pos0'), vel0'), 
                 if evt1 /= NoEvent then evt1 `tag` (((fst pos0') + 0.5, snd pos0'), vel0', 1)
                 else if evt2 /= NoEvent then evt2 `tag` (((fst pos0') - 0.5, snd pos0'), vel0', 2)
                      else evt3 `tag` (((fst pos0') + dCount (oeInput input) - aCount (oeInput input), snd pos0'), vel0', 3))

最初のサイクルで起きる事象は次の四つである。
①右矢印のキーが押される。
②左矢印のキーが押される。
③PgUpが押される。
④aまたはdのキーが押される。
⑤何も押されない。

①、②、③イベントevt1,evt2,evt3としてそれぞれ検出される。④、⑤はイベントとしては検出されず単にオブジェクトの新しい状態を送り出す。
イベントとして検知された時は、switchによって、これまでのstayingBall pos0 vel0の関数が、①、②の場合にはstayingBall pos velに、③の場合にはbouncingBall pos velに切り替わる。①あるいは②の場合には、右矢印あるいは右矢印によって、石が右あるいは左に移されているので、posは石の新しい位置となる。③の場合には石は移動させていないので、posはpos0のままである。

さて、注意する必要があるのは、④の場合である。この場合には、キーdあるいはaによって、右側か左側に移動させられる。従って、石の現在位置は移動させられた位置posとなる。プログラムでいうとreturnAのすぐ右にある((fst pos0') + dCount (oeInput input) - aCount (oeInput input), snd pos0')である。しかし、信号関数は切り替わっていないので、次のサイクルではstayingBall pos0 vel0を使うこととなる。

これらの状況をまとめると以下のようになる。ここでは、最初のサイクルが始まるときの石の位置を\(pos_0\)、 最初のサイクルが終わるときの石の位置を\(pos_1\)とする。二番目のサイクルでの石の状態と信号関数の関係を表すと以下のようになる。

最初のサイクルでの事象 二番目のサイクルの最初の状態(位置) 二番目のサイクルでの信号関数(位置)
\(pos_1\) \(stayingBall (pos_1)\)
\(pos_1\) \(stayingBall (pos_1)\)
\(pos_0\) \(bouncingBall (pos_0)\)
\(pos_1\) \(stayingBall (pos_0)\)
\(pos_0\) \(stayingBall (pos_0)\)

上の表から、④の場合だけ、サイクル開始時にdefaultObjOutputから渡される位置と信号関数をスタートさせるときの位置とがずれていることに気が付く。このため、次のような処理が必要となる。

最初のサイクルでキーaまたはdを移動させ、二番目のサイクルで石を発射させたとする。この時、二番目のサイクルでは、stayingBallへの入力は\(pos_0\)となる。しかし、石の現在位置は\(pos_1\)へと移動しているので、bouncingBallへの位置の入力は\(pos_0\)ではなく現在の位置\(pos_1\)にする必要がある。このため、プログラムでは、eve3に対して((fst pos0') + dCount (oeInput input) - aCount (oeInput input), snd pos0')を行っている(かなり面倒くさいので、④のところが一致するように改善してくれるとよいと思うが、それを実現してくれたのがNetwireである)。

これらの処理はそれ以降のサイクルでも同じである。これを一般化させてプログラム化したのが、stayingBallである。

4.石と鳥の衝突

石と鳥の衝突を検出しているのは次のプログラムである。

route :: (ParsedInput, IL ObjOutput) -> IL sf -> IL (ObjEvents, sf)
route (input, oos) objs = mapIL routeAux objs
  where
    hs = hits (assocsIL (fmap ooState oos))
    routeAux (k, obj) = (ObjEvents
      { oeInput = input,
        oeLogic = if k `elem` hs then Event () else NoEvent
      }, obj)

上記のプログラムで、衝突を検出しているのがhitsという関数である。また、hsは衝突したオブジェクト全てを含むリストである。関数routeAuxによって、衝突したオブジェクトは、「衝突した」という状態を有する。

一つのサイクルが終了するとき、鳥のその時の状態がdefaultObjObjectによって返される。プログラムは次のようになっている。

birdObject :: Pos -> Vel -> Object
birdObject pos0 vel0  = proc input -> do
  rec
    (pos, vel) <- stayingBird pos0 vel0 -< input
  returnA -< defaultObjOutput{ooKind = Bird, ooState = State {position' = pos, velocity = vel},
                              ooKillRequest = (oeLogic input)}

ゲームから消えて欲しいいう要求がooKillRequestとして記憶され、次のサイクルに渡される。
次のサイクルでは、関数killAndSpawnによって、鳥が識別リストから除外され、ゲームの登場者ではなくなる。

killAndSpawn :: ((ParsedInput, IL ObjOutput), IL ObjOutput)
             -> Event (IL Object -> IL Object)
killAndSpawn ((input, _), oos) =
  if isEvent (downEvs input)
    then Event (\_ -> emptyIL) 
  else foldl (mergeBy (.)) noEvent events
  where
    events :: [Event (IL Object -> IL Object)]
    events = [ mergeBy (.)
                      (ooKillRequest oo `tag` (deleteIL k))
                      (fmap  (foldl (.) id . map insertIL_)
                             (ooSpawnRequests oo))
             | (k, oo) <- assocsIL oos ]

上記のプログラムには、PgDnキーが押された時、すべてのオブジェクトを識別リストから除外するコードも含まれている。

以上がオブジェクトの一生である。