helm-1.0.0: examples/flappy/Main.hs
{-# LANGUAGE RecordWildCards #-}
-- | A Flappy Bird clone. Click to flap.
-- Avoid the grey obstacles and don't touch the lava.
module Main where
import Data.List (find)
import Data.Maybe (isJust)
import Debug.Trace (traceShow)
import Text.Printf (printf)
import Linear.V2 (V2(V2))
import qualified System.Random as Rand
import Helm
import qualified Helm.Cmd as Cmd
import Helm.Color
import Helm.Engine.SDL (SDLEngine)
import qualified Helm.Engine.SDL as SDL
import Helm.Graphics2D
import qualified Helm.Graphics2D.Text as Text
import qualified Helm.Keyboard as Keyboard
import qualified Helm.Mouse as Mouse
import qualified Helm.Sub as Sub
import qualified Helm.Time as Time
import Helm.Time (Time)
-- | Represents the game actions for our game.
data Action
= DoNothing -- ^ Do nothing.
| Animate Double -- ^ Animate the player with a dt.
| Flap -- ^ Flap the player.
| Restart -- ^ Restart the game after dying.
| SetupObstacles Rand.StdGen -- ^ Setup the obstacles using an RNG.
-- | Represents the status of the player (i.e. where they're at).
data PlayerStatus
= Playing -- ^ The player is playing the game.
| Waiting -- ^ The player is waiting and needs to click to start the game.
| Dead -- ^ The player is dead and needs to hit space to get to the waiting state.
deriving (Eq, Ord, Show)
-- | Represents an obstacle the flapper can hit.
data Obstacle
= NoObstacle -- ^ The obstacle exists, but it's just empty (not visible and uncollidable).
| Obstacle -- ^ An actually collidable obstacle.
{ obsTopLeft :: V2 Double
, obsBottomRight :: V2 Double
} deriving (Eq, Ord, Show)
-- | Represents the game state of the game.
data Model = Model
{ flapperPos :: V2 Double
, flapperVel :: V2 Double
, playerStatus :: PlayerStatus
, obstacles :: [Obstacle]
, timeScore :: Time
, timeSpeed :: Double
}
initial :: (Model, Cmd SDLEngine Action)
initial =
( Model
{ flapperPos = V2 0 0
, flapperVel = V2 0 0
, playerStatus = Waiting
, obstacles = []
, timeScore = 0
, timeSpeed = 1
}
, Cmd.execute Rand.newStdGen SetupObstacles
)
-- | The gravity acceleration for the flapper.
-- Note that the Y component is positive as the downwards
-- direction in our view is the positive end of the Y-axis.
-- The origin (0, 0) is the center of the screen.
gravity :: V2 Double
gravity = V2 0 7
lavaHeight :: Double
lavaHeight = 65
windowDims :: V2 Int
windowDims = V2 800 600
scrollVel :: V2 Double
scrollVel = V2 4 0
obsWidth :: Double
obsWidth = 70
obsMargin :: Double
obsMargin = 50
obsOffset :: Double
obsOffset = (obsMargin + obsWidth) * 6
flapperDims :: V2 Double
flapperDims = V2 50 50
-- | Only the obstacles the player has seen/can see.
relevantObs :: Model -> [Obstacle]
relevantObs Model { .. } =
take n obstacles
where
V2 x _ = flapperPos
V2 w _ = fromIntegral <$> windowDims
n = max 0 $ floor $ (x - obsOffset + w) / (obsMargin + obsWidth)
-- | Are any obstacles touching the flapper?
touchingObs :: Model -> Bool
touchingObs model@Model { .. } =
isJust $ find f $ relevantObs model
where
V2 x y = flapperPos
V2 w h = flapperDims
-- The flapper pos is centered. Turn it into a box.
ftx = x - w / 2
fty = y - h / 2
fbx = x + w / 2
fby = y + h / 2
-- Check if the flapper box and obs box intersect.
-- If so, we're dead.
f NoObstacle = False
f Obstacle { .. } =
max tx ftx < min bx fbx &&
max ty fty < min by fby
where
V2 tx ty = obsTopLeft
V2 bx by = obsBottomRight
-- | Is our flapper touching the lava at the bottom of the screen?
inLava :: Model -> Bool
inLava Model { .. } =
y + fh / 2 >= h / 2 - lavaHeight
where
V2 _ fh = flapperDims
V2 x y = flapperPos
V2 w h = fromIntegral <$> windowDims
-- | Should our flapper die? Only checks if they should -
-- DOES NOT transition the player status to dead.
shouldDie :: Model -> Bool
shouldDie model = inLava model || touchingObs model
update :: Model -> Action -> (Model, Cmd SDLEngine Action)
update model@Model { .. } (Animate dt) =
if playerStatus == Waiting then (model, Cmd.none)
else
( model
{ flapperPos = if y < -hh
then V2 x (-hh)
else pos
, flapperVel = vel
, playerStatus = if dead
then Dead
else Playing
, timeScore = elapsed
, timeSpeed = speed
}
, Cmd.none
)
where
-- | If the player is actually playing, increase the score.
-- They might be at the death screen (which is also animated).
elapsed = if dead
then timeScore
else timeScore + dt
dt' = dt * 0.005
gravity' = gravity * V2 dt' dt'
-- Make the movement right faster as the player gets further across.
speed = logBase 10 (10 + Time.inSeconds elapsed)
vel = if dead
then V2 0 1 * (flapperVel + gravity') -- No x-velocity while dead.
else flapperVel + gravity'
pos@(V2 x y) = flapperPos + vel + if dead
then V2 0 0
else scrollVel * V2 speed 0
V2 _ h = fromIntegral <$> windowDims
hh = h / 2
dead = (playerStatus == Dead) || shouldDie model
-- | The player has clicked using their mouse.
-- | Process the "flap" of our flapper's wings.
update model@Model { .. } Flap =
if playerStatus == Dead
then (model, Cmd.none)
else
( model
{ flapperVel = V2 0 (-17)
, playerStatus = Playing
}
, Cmd.none
)
-- | The player has pressed space while on the death screen.
-- Restart the game.
update model@Model { .. } Restart =
if playerStatus /= Dead
then (model, Cmd.none)
else
( model
{ playerStatus = Waiting
, flapperPos = V2 0 0
, flapperVel = V2 0 0
, timeScore = 0
}
-- Trigger a regeneration of obstacles
, Cmd.execute Rand.newStdGen SetupObstacles
)
-- | Initialize a list of all the obstacles in the game.
-- This works really nicely, as the list of obstacles
-- we produce is lazy and we can just keep generating
-- new obstacles out of until infinity - which is perfect,
-- as our player might just keep playing the game
-- for an eternity and beyond.
update model (SetupObstacles rng) =
(model
{ obstacles = scanl generate NoObstacle $
zip [0..] $
Rand.randoms rng
}
, Cmd.none
)
where
-- | Generate an obstacle based based off the last obstacle
-- generated and some random input.
generate
:: Obstacle -- | The last obstacle generated (or 'NoObstacle' if first).
-> (Int, Double) -- | First element is the obstacle index, second is random input between [0,1).
-> Obstacle -- | The generated obstacle.
generate last (i, n) =
-- Randomly exclude obstacles, but don't do it for the first one.
if i > 0 && (n < lb || n > hb)
then NoObstacle
else
Obstacle
{ obsTopLeft = V2 x y
, obsBottomRight = V2 (x + obsWidth) (y + height)
}
where
lb = 0.2
hb = 0.8
-- We have to normalize the value here as we ignored < lb and > hb above.
-- Let's get this back to (0, 1]
n' = (n - lb) / (hb - lb)
x = obsOffset + (obsWidth + obsMargin) * fromIntegral i
V2 _ h = fromIntegral <$> windowDims
h' = h - lavaHeight
hh' = h / 2
minHeight = 100
maxHeight = 300
(y, height) = calc last
-- | Calc the obstacle height and
calc NoObstacle =
-- Generate a random y and random height. This can be anywhere on the screen as
-- we're not next to an older obstacle.
(-hh' + n' * h', height')
where
height' = minHeight + (maxHeight - minHeight) * n'
calc Obstacle { .. } =
-- We want the obstacle being generated to be in a similar position
-- to the last one generated. So we adjust the last position randomly
-- by a portion of the last generated obstacle's height.
( max (-hh') $ min hh' $ ty + (n' - 0.5) * maxHeight
, height')
where
V2 tx ty = obsTopLeft
V2 bx by = obsBottomRight
lastHeight = by - ty
height' = min maxHeight $ lastHeight + (n' - 0.5) * minHeight
-- | Do nothing.
update model _ = (model, Cmd.none)
subscriptions :: Sub SDLEngine Action
subscriptions = Sub.batch
[ Mouse.clicks $ \_ _ -> Flap
, Keyboard.presses $ \key -> (case key of
Keyboard.SpaceKey -> Restart
_ -> DoNothing)
, Time.fps 60 Animate
]
-- | Turn some second value into a pretty pluralized form (for UI).
secondsText :: Time -> String
secondsText t =
show s ++ if s /= 1 then " seconds" else " second"
where
s = round $ Time.inSeconds t
-- | The overlay displayed when the player is dead.
deadOverlay :: Color -> Model -> Form SDLEngine
deadOverlay color Model { .. } =
group
[ move (V2 0 (-25)) $ text $ Text.height 30 $
Text.color color $
Text.toText "Oops, you're dead."
, move (V2 0 25) $ text $ Text.height 12 $
Text.color color $
Text.toText score
, move (V2 0 50) $ text $ Text.height 12 $
Text.color color $
Text.toText "Press space to restart"
]
where
score = "You lasted " ++ secondsText timeScore
-- | The overlay displayed when the player is waiting to play.
waitingOverlay :: Color -> Form SDLEngine
waitingOverlay color =
group
[ move (V2 0 (-75)) $ text $ Text.height 30 $
Text.color color $
Text.toText "Ready?"
, move (V2 0 75) $ text $ Text.height 12 $
Text.color color $
Text.toText "Click to flap"
]
-- | The overlay when playing the game (i.e. HUD).
playingOverlay :: Color -> Model -> Form SDLEngine
playingOverlay color Model { .. } =
group
[
move (V2 0 (-h / 2 + 25)) $ text $ Text.height 12 $
Text.color color $
Text.toText status
]
where
status = secondsText timeScore ++ " | " ++ printf "%.2fx speed" timeSpeed
V2 _ h = fromIntegral <$> windowDims
view :: Model -> Graphics SDLEngine
view model@Model { .. } = Graphics2D $
center (V2 (w / 2) (h / 2)) $ collage
[ backdrop
, toForm $ center (V2 (-x) 0) $ collage
[ move flapperPos flapper
, group $ map structure $ relevantObs model
]
, lava
, overlay playerStatus model
]
where
dims@(V2 w h) = fromIntegral <$> windowDims
V2 x y = flapperPos
overlayColor = rgb 1 1 1
overlay Waiting _ = waitingOverlay overlayColor
overlay Dead model = deadOverlay overlayColor model
overlay Playing model = playingOverlay overlayColor model
flapper = filled (rgb 0.36 0.25 0.22) $ rect flapperDims
backdrop = filled (rgb 0.13 0.13 0.13) $ rect dims
lava = move (V2 0 (h / 2 - lavaHeight / 2)) $ filled (rgb 0.72 0.11 0.11) $ rect $ V2 w lavaHeight
structure NoObstacle = blank
structure Obstacle { .. } =
move (V2 ((tx + bx) / 2) ((ty + by) / 2)) $ filled (rgb 0.38 0.49 0.55) $ rect $ V2 (bx - tx) (by - ty)
where
V2 tx ty = obsTopLeft
V2 bx by = obsBottomRight
main :: IO ()
main = do
engine <- SDL.startupWith $ SDL.defaultConfig
{ SDL.windowIsResizable = False
, SDL.windowDimensions = windowDims }
run engine GameConfig
{ initialFn = initial
, updateFn = update
, subscriptionsFn = subscriptions
, viewFn = view
}