packages feed

brillo-examples-2.1.0: picture/SpaceInvaders/Main.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}

module Main (main) where

import Brillo
import Brillo.Data.Point (pointInBox)
import Brillo.Interface.Pure.Game
import Data.Function ((&))
import Data.List (partition)
import Data.List qualified as List
import Data.Text qualified as T


-- Constants

windowWidth, windowHeight :: Float
windowWidth = 640
windowHeight = 480


playerY :: Float
playerY = -200


playerSpeed :: Float
playerSpeed = 200


bulletSpeed :: Float
bulletSpeed = 300


alienBulletSpeed :: Float
alienBulletSpeed = 150


alienStepX :: Float
alienStepX = 20


alienStepY :: Float
alienStepY = 20


alienMoveInterval :: Float
alienMoveInterval = 0.6


alienShootInterval :: Float
alienShootInterval = 1.2


cols :: Int
cols = 11


rows :: Int
rows = 5


totalAliens :: Int
totalAliens = cols * rows


-- Types

data Alien = Octopus | Crab | Squid


data Direction = DirLeft | DirRight


flipDir :: Direction -> Direction
flipDir DirLeft = DirRight
flipDir DirRight = DirLeft


dirSign :: Direction -> Float
dirSign DirLeft = -1
dirSign DirRight = 1


data AlienInfo = AlienInfo
  { alienType :: Alien
  , alienPos :: (Float, Float)
  , alive :: Bool
  }


data Bullet = Bullet
  { bulletPos :: (Float, Float)
  }


data Shield = Shield
  { shieldPos :: (Float, Float)
  , shieldHealth :: Int
  }


data Phase = WaitingToStart | Playing | Won | Dead
  deriving (Eq)


data Keys = Keys
  { leftHeld :: Bool
  , rightHeld :: Bool
  }


data GameState = GameState
  { playerX :: Float
  , playerBullet :: Maybe Bullet
  , alienBullets :: [Bullet]
  , aliens :: [AlienInfo]
  , aliveCount :: Int
  , shields :: [Shield]
  , alienDir :: Direction
  , alienTimer :: Float
  , alienShootTimer :: Float
  , score :: Int
  , lives :: Int
  , phase :: Phase
  , moveDown :: Bool
  , keysHeld :: Keys
  }


-- Alien scoring

alienToPoints :: Alien -> Int
alienToPoints = \case
  Octopus -> 10
  Crab -> 20
  Squid -> 30


-- Initial state

makeAlienGrid :: [AlienInfo]
makeAlienGrid =
  [ AlienInfo
      { alienType = alienForRow r
      , alienPos = (fromIntegral c * 40 - 200, fromIntegral r * 35 + 40)
      , alive = True
      }
  | r <- [0 .. rows - 1]
  , c <- [0 .. cols - 1]
  ]
  where
    alienForRow r
      | r < 2 = Octopus
      | r < 4 = Crab
      | otherwise = Squid


makeShields :: [Shield]
makeShields =
  [ Shield (x, -140) 4
  | x <- [-180, -60, 60, 180]
  ]


initialState :: GameState
initialState =
  GameState
    { playerX = 0
    , playerBullet = Nothing
    , alienBullets = []
    , aliens = makeAlienGrid
    , aliveCount = totalAliens
    , shields = makeShields
    , alienDir = DirRight
    , alienTimer = 0
    , alienShootTimer = 0
    , score = 0
    , lives = 3
    , phase = WaitingToStart
    , moveDown = False
    , keysHeld = Keys False False
    }


-- Rendering

render :: GameState -> Picture
render gs =
  Pictures $
    concat
      [ [drawPlayer gs.playerX]
      , map drawAlienInfo (filter (.alive) gs.aliens)
      , maybe [] (\b -> [drawBullet green b]) gs.playerBullet
      , map (drawBullet white) gs.alienBullets
      , map drawShield gs.shields
      , [drawLabel (-300) "SCORE: " gs.score, drawLabel 200 "LIVES: " gs.lives]
      , overlay
      ]
  where
    drawPlayer :: Float -> Picture
    drawPlayer px =
      Color green $
        Translate px playerY $
          Pictures
            [ rectangleSolid 30 10
            , Translate 0 7 $ rectangleSolid 6 10
            ]

    drawAlienInfo :: AlienInfo -> Picture
    drawAlienInfo ai =
      let (ax, ay) = ai.alienPos
      in  Color (alienColor ai.alienType) $
            Translate ax ay $
              drawAlien ai.alienType

    drawAlien :: Alien -> Picture
    drawAlien Octopus =
      Pictures
        [ rectangleSolid 16 12
        , Translate (-8) (-6) $ rectangleSolid 4 6
        , Translate 8 (-6) $ rectangleSolid 4 6
        ]
    drawAlien Crab =
      Pictures
        [ rectangleSolid 20 10
        , Translate (-10) 2 $ rectangleSolid 4 8
        , Translate 10 2 $ rectangleSolid 4 8
        ]
    drawAlien Squid =
      Pictures
        [ rectangleSolid 14 14
        , Translate 0 10 $ rectangleSolid 6 6
        ]

    alienColor :: Alien -> Color
    alienColor Octopus = cyan
    alienColor Crab = white
    alienColor Squid = magenta

    drawBullet :: Color -> Bullet -> Picture
    drawBullet clr b =
      let (bx, by) = b.bulletPos
      in  Color clr $ Translate bx by $ rectangleSolid 2 8

    drawShield :: Shield -> Picture
    drawShield s =
      let (sx, sy) = s.shieldPos
          intensity = fromIntegral s.shieldHealth / 4.0
      in  Color (makeColor 0 intensity 0 1) $
            Translate sx sy $
              rectangleSolid 40 20

    drawLabel :: Float -> String -> Int -> Picture
    drawLabel tx label n =
      Color white $
        Translate tx 220 $
          Scale 0.15 0.15 $
            Text $
              T.pack (label ++ show n)

    overlay = case gs.phase of
      WaitingToStart ->
        [ Color white $
            Translate (-155) 0 $
              Scale 0.2 0.2 $
                Text "Press SPACE to start."
        ]
      Dead ->
        [ Color red $
            Translate (-200) 0 $
              Scale 0.5 0.5 $
                Text "GAME OVER"
        , Color white $
            Translate (-175) (-50) $
              Scale 0.2 0.2 $
                Text "Press SPACE to try again."
        ]
      Won ->
        [ Color green $
            Translate (-160) 0 $
              Scale 0.5 0.5 $
                Text "YOU WIN!"
        , Color white $
            Translate (-175) (-50) $
              Scale 0.2 0.2 $
                Text "Press SPACE to play again."
        ]
      Playing -> []


-- Input handling

handleInput :: Event -> GameState -> GameState
handleInput (EventKey (SpecialKey KeySpace) Down _ _) gs = case gs.phase of
  WaitingToStart -> gs{phase = Playing}
  Dead -> initialState{phase = Playing}
  Won -> initialState{phase = Playing}
  Playing -> shoot gs
handleInput (EventKey (SpecialKey KeyLeft) ks _ _) gs =
  gs{keysHeld = (gs.keysHeld){leftHeld = ks == Down}}
handleInput (EventKey (SpecialKey KeyRight) ks _ _) gs =
  gs{keysHeld = (gs.keysHeld){rightHeld = ks == Down}}
handleInput _ gs = gs


shoot :: GameState -> GameState
shoot gs = case gs.playerBullet of
  -- Classic Space Invaders only allows one player bullet on screen
  Just _ -> gs
  Nothing ->
    gs{playerBullet = Just Bullet{bulletPos = (gs.playerX, playerY + 15)}}


-- Update

update :: Float -> GameState -> GameState
update dt gs
  | gs.phase /= Playing = gs
  | otherwise =
      gs
        & movePlayer dt
        & movePlayerBullet dt
        & moveAlienBullets dt
        & stepAliens dt
        & alienShooting dt
        & checkCollisions
        & checkWinLose


movePlayer :: Float -> GameState -> GameState
movePlayer dt gs =
  let Keys l r = gs.keysHeld
      dx
        | l && not r = -playerSpeed * dt
        | r && not l = playerSpeed * dt
        | otherwise = 0
  in  gs{playerX = clamp (-280) 280 (gs.playerX + dx)}


clamp :: Float -> Float -> Float -> Float
clamp lo hi x = max lo (min hi x)


movePlayerBullet :: Float -> GameState -> GameState
movePlayerBullet dt gs = case gs.playerBullet of
  Nothing -> gs
  Just b ->
    let (bx, by) = b.bulletPos
        newY = by + bulletSpeed * dt
    in  if newY > windowHeight / 2
          then gs{playerBullet = Nothing}
          else gs{playerBullet = Just b{bulletPos = (bx, newY)}}


moveAlienBullets :: Float -> GameState -> GameState
moveAlienBullets dt gs =
  let lowerBound = -(windowHeight / 2)
      step b =
        let (bx, by) = b.bulletPos
        in  b{bulletPos = (bx, by - alienBulletSpeed * dt)}
      keep b = snd b.bulletPos > lowerBound
  in  gs{alienBullets = filter keep (map step gs.alienBullets)}


stepAliens :: Float -> GameState -> GameState
stepAliens dt gs
  | newTimer < currentInterval gs = gs{alienTimer = newTimer}
  | gs.moveDown = moveAliensDown gs'
  | otherwise = moveAliensSideways gs'
  where
    newTimer = gs.alienTimer + dt
    gs' = gs{alienTimer = 0}


-- Speed up as aliens are destroyed
currentInterval :: GameState -> Float
currentInterval gs =
  let ratio = fromIntegral gs.aliveCount / fromIntegral totalAliens
  in  max 0.05 (alienMoveInterval * ratio)


mapAlive :: ((Float, Float) -> (Float, Float)) -> [AlienInfo] -> [AlienInfo]
mapAlive f = map (\a -> if a.alive then a{alienPos = f a.alienPos} else a)


moveAliensSideways :: GameState -> GameState
moveAliensSideways gs =
  let dx = alienStepX * dirSign gs.alienDir
      moved = mapAlive (\(x, y) -> (x + dx, y)) gs.aliens
      extent (lo, hi) a
        | not a.alive = (lo, hi)
        | otherwise = let x = fst a.alienPos in (min lo x, max hi x)
      (minX, maxX) = List.foldl' extent (1 / 0, -(1 / 0)) moved
      hitEdge = maxX > 280 || minX < -280
  in  gs{aliens = moved, moveDown = hitEdge}


moveAliensDown :: GameState -> GameState
moveAliensDown gs =
  gs
    { aliens = mapAlive (\(x, y) -> (x, y - alienStepY)) gs.aliens
    , alienDir = flipDir gs.alienDir
    , moveDown = False
    }


alienShooting :: Float -> GameState -> GameState
alienShooting dt gs
  | newTimer < alienShootInterval = gs{alienShootTimer = newTimer}
  | null aliveAliens = gs{alienShootTimer = 0}
  | otherwise =
      let shooterIdx = floor (newTimer * 1000) `mod` length aliveAliens
          shooter = aliveAliens !! shooterIdx
          (sx, sy) = shooter.alienPos
          newBullet = Bullet{bulletPos = (sx, sy - 15)}
      in  gs
            { alienBullets = newBullet : gs.alienBullets
            , alienShootTimer = 0
            }
  where
    newTimer = gs.alienShootTimer + dt
    aliveAliens = filter (.alive) gs.aliens


checkCollisions :: GameState -> GameState
checkCollisions gs =
  gs
    & playerBulletHitsAliens
    & alienBulletsHitPlayer
    & bulletsHitShields


playerBulletHitsAliens :: GameState -> GameState
playerBulletHitsAliens gs = case gs.playerBullet of
  Nothing -> gs
  Just b ->
    let (hit, newAliens, points) = checkAlienHits b gs.aliens
    in  if hit
          then
            gs
              { playerBullet = Nothing
              , aliens = newAliens
              , aliveCount = gs.aliveCount - 1
              , score = gs.score + points
              }
          else gs


checkAlienHits :: Bullet -> [AlienInfo] -> (Bool, [AlienInfo], Int)
checkAlienHits _ [] = (False, [], 0)
checkAlienHits b (a : as)
  | a.alive && bulletHits 12 10 b a.alienPos =
      (True, a{alive = False} : as, alienToPoints a.alienType)
  | otherwise =
      let (hit, rest, pts) = checkAlienHits b as
      in  (hit, a : rest, pts)


bulletHits :: Float -> Float -> Bullet -> (Float, Float) -> Bool
bulletHits hw hh b (cx, cy) =
  pointInBox b.bulletPos (cx - hw, cy - hh) (cx + hw, cy + hh)


alienBulletsHitPlayer :: GameState -> GameState
alienBulletsHitPlayer gs =
  let playerPos = (gs.playerX, playerY)
      (hitBullets, safeBullets) =
        partition (\b -> bulletHits 15 10 b playerPos) gs.alienBullets
  in  if null hitBullets
        then gs
        else gs{alienBullets = safeBullets, lives = gs.lives - 1}


bulletsHitShields :: GameState -> GameState
bulletsHitShields gs =
  let (pb', shields1) = case gs.playerBullet of
        Nothing -> (Nothing, gs.shields)
        Just b -> checkBulletShields b gs.shields
      (alienBullets', shields2) =
        foldr checkAlienBullet ([], shields1) gs.alienBullets
  in  gs{playerBullet = pb', alienBullets = alienBullets', shields = shields2}
  where
    checkBulletShields :: Bullet -> [Shield] -> (Maybe Bullet, [Shield])
    checkBulletShields b [] = (Just b, [])
    checkBulletShields b (s : ss)
      | s.shieldHealth > 0 && bulletHits 22 12 b s.shieldPos =
          (Nothing, s{shieldHealth = s.shieldHealth - 1} : ss)
      | otherwise =
          let (b', ss') = checkBulletShields b ss
          in  (b', s : ss')

    checkAlienBullet :: Bullet -> ([Bullet], [Shield]) -> ([Bullet], [Shield])
    checkAlienBullet b (bs, ss) =
      case checkBulletShields b ss of
        (Just b', ss') -> (b' : bs, ss')
        (Nothing, ss') -> (bs, ss')


checkWinLose :: GameState -> GameState
checkWinLose gs
  | gs.lives <= 0 = gs{phase = Dead}
  | gs.aliveCount == 0 = gs{phase = Won}
  | any alienTooLow gs.aliens = gs{phase = Dead}
  | otherwise = gs
  where
    alienTooLow a = a.alive && snd a.alienPos < playerY + 30


main :: IO ()
main =
  play
    (InWindow "Space Invaders" (round windowWidth, round windowHeight) (100, 100))
    black
    60
    initialState
    render
    handleInput
    update