packages feed

spanout-0.1: src/Spanout/Gameplay.hs

{-# LANGUAGE Arrows #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE TypeOperators #-}

module Spanout.Gameplay (game) where

import Prelude hiding (id, (.))

import Spanout.Common
import Spanout.Graphics
import Spanout.Level
import qualified Spanout.Wire as Wire

import Control.Applicative
import Control.Arrow
import Control.Category
import Control.Lens
import Control.Monad

import Data.Either
import Data.Maybe
import Data.Monoid
import qualified Data.Set as Set

import qualified Graphics.Gloss.Interface.IO.Game as Gloss

import Linear



-- The reactive view of the game
game :: a ->> Gloss.Picture
game = Wire.bindW gsInit gameBegin
  where
    gsInit = do
      bricks <- generateBricks
      return GameState
        { _gsBall = ballInit
        , _gsBatX = 0
        , _gsBricks = bricks
        }



-- Displays the level and a countdown before the actual gameplay
gameBegin :: GameState -> a ->> Gloss.Picture
gameBegin gsInit = Wire.switch $ proc _ -> do
  batX <- view _x ^<< mousePos -< ()
  time <- Wire.time -< ()
  let
    gs = set gsBatX batX gsInit
    remainingTime = countdownTime - time
  returnA -< if
    | remainingTime > 0 -> Right $ gamePic gs <> countdownPic remainingTime
    | otherwise         -> Left  $ gameLevel gs

-- Gameplay from an initial game state
gameLevel :: GameState -> a ->> Gloss.Picture
gameLevel gsInit = Wire.switch $ proc _ -> do
  batX <- view _x ^<< mousePos -< ()
  rec
    -- Binding previous values
    ball' <- Wire.delay $ view gsBall gsInit -< ball
    bricks' <- Wire.delay $ view gsBricks gsInit -< bricks

    -- Current position
    pos <- Wire.accum (\dt p v -> p + dt *^ v) $ view (gsBall . ballPos) gsInit
        -< view ballVel ball'
    -- Collision and its normal
    let
      edgeNormal = ballEdgeNormal pos
      batNormal = ballBatNormal batX pos
      ballBrickColl = ballBrickCollision (ball' {_ballPos = pos}) bricks'
      brickNormals = fst <$> ballBrickColl
      normal = mfilter (faceAway $ view ballVel ball') . mergeNormalEvents $
           maybeToList edgeNormal
        ++ maybeToList batNormal
        ++ fromMaybe [] brickNormals
    -- Current velocity
    vel <- Wire.accumE reflect $ view (gsBall . ballVel) gsInit -< normal

    -- Binding current values
    let
      ball = Ball pos vel
      bricks = fromMaybe bricks' (snd <$> ballBrickColl)

  let gs = GameState {_gsBall = ball, _gsBatX = batX, _gsBricks = bricks}
  spacePressed <- keyPressed $ Gloss.SpecialKey Gloss.KeySpace -< ()
  returnA -< if
    | spacePressed ->
        Left game
    | null bricks ->
        Left $ levelEnd gs
    | view (ballPos . _y) ball <= -screenBoundY - ballRadius ->
        Left $ gameBegin gsInit
    | otherwise ->
        Right $ gamePic gs

-- Displays the final game state for some time after the end of the level
levelEnd :: GameState -> a ->> Gloss.Picture
levelEnd gs = Wire.switch $ Wire.forThen levelEndTime game . pure pic
  where
    pic = gamePic gs <> levelEndPic

-- The sum of zero or more normals
mergeNormalEvents :: (Floating a, Epsilon a) => [V2 a] -> Maybe (V2 a)
mergeNormalEvents [] = Nothing
mergeNormalEvents normals = Just . normalize $ sum normals

-- Collision between the ball and the screen edges
ballEdgeNormal :: V2 Float -> Maybe (V2 Float)
ballEdgeNormal (V2 px py)
  | px <= -screenBoundX + ballRadius = Just $  unit _x
  | px >=  screenBoundX - ballRadius = Just $ -unit _x
  | py >=  screenBoundY - ballRadius = Just $ -unit _y
  | otherwise                        = Nothing

-- Collision between the ball and the bat
ballBatNormal :: Float -> V2 Float -> Maybe (V2 Float)
ballBatNormal batX (V2 px py)
  | bxl && bxr && by = Just $ batNormalAt px batX
  | otherwise        = Nothing
  where
    bxl = px >= batX - batWidth / 2
    bxr = px <= batX + batWidth / 2
    by  = py <= batPositionY + batHeight / 2 + ballRadius

-- Collision between the ball and the bricks.
-- Calculates the resulting normals and the remaining bricks.
ballBrickCollision :: Ball -> [Brick] -> Maybe ([V2 Float], [Brick])
ballBrickCollision ball bricks =
  case collisionNormals of
    [] -> Nothing
    _  -> Just (collisionNormals, remBricks)
  where
    check brick =
      case ballBrickNormal brick ball of
        Just normal -> Right normal
        _           -> Left brick
    (remBricks, collisionNormals) = partitionEithers . map check $ bricks

-- Collision between the ball and a brick
ballBrickNormal :: Brick -> Ball -> Maybe (V2 Float)
ballBrickNormal (Brick pos (Circle radius)) (Ball bpos _)
  | hit = Just . normalize $ bpos - pos
  | otherwise = Nothing
  where
    hit = distance bpos pos <= radius + ballRadius
ballBrickNormal (Brick pos@(V2 x y) (Rectangle width height)) (Ball bpos bvel)
  | tooFar = Nothing
  | hitX = Just normalX
  | hitY = Just normalY
  | hitCorner = listToMaybe . filter (faceAway bvel) $ [normalX, normalY]
  | otherwise = Nothing
  where
    dist = bpos - pos
    V2 distAbsX distAbsY = abs <$> dist
    V2 ballX ballY = bpos
    tooFar = distAbsX > width  / 2 + ballRadius
          || distAbsY > height / 2 + ballRadius
    hitX = distAbsX <= width / 2
    hitY = distAbsY <= height / 2
    hitCorner = quadrance (V2 (distAbsX - width / 2) (distAbsY - height / 2))
             <= ballRadius ^ (2 :: Int)
    normalX = signum (ballY - y) *^ unit _y
    normalY = signum (ballX - x) *^ unit _x

-- The normal at a point of the bat
batNormalAt :: Float -> Float -> V2 Float
batNormalAt x batX = perp . angle $ batSpread * relX
  where
    relX = (batX - x) / (batWidth / 2)

-- Checks if two vectors face away from each other
faceAway :: (Num a, Ord a) => V2 a -> V2 a -> Bool
faceAway u v = u `dot` v < 0

-- The reflection of a vector based on a normal
reflect :: Num a => V2 a -> V2 a -> V2 a
reflect v normal = v - (2 * v `dot` normal) *^ normal

-- The reactive position of the mouse
mousePos :: a ->> V2 Float
mousePos = Wire.constM $ view envMouse

-- The reactive state of a keyboard button
keyPressed :: Gloss.Key -> a ->> Bool
keyPressed key = Wire.constM $ views envKeys (Set.member key)