1 module Game (gameSettings, G.getStdGen, G.playGame) where
3 import Data.Bifunctor (bimap)
4 import Data.Char (toUpper)
5 import Data.Maybe (listToMaybe)
6 import System.Random (Random(..))
7 import qualified Control.Monad.State as S
8 import qualified Terminal.Game as G
10 type RandomState = S.State G.StdGen
12 data Screen = TitleScreen | HelpScreen | GameScreen
14 data Direction = U | D | L | R deriving (Bounded, Enum)
16 instance Random Direction where
17 random g = randomR (minBound, maxBound) g
18 randomR (lower, upper) g = case randomR (fromEnum lower, fromEnum upper) g of (r, g') -> (toEnum r, g')
24 data Character t = Character { entityCoords :: !G.Coords
25 , entityDirection :: !(Maybe Direction) }
27 drawPlayer :: Character Player -> (G.Coords, G.Plane)
28 drawPlayer character = (entityCoords character, G.color G.Blue G.Vivid $ G.cell 'P')
30 drawBox :: Character Box -> (G.Coords, G.Plane)
31 drawBox character = (entityCoords character, G.color G.Green G.Dull $ G.cell 'O')
33 drawEnemy :: Character Enemy -> (G.Coords, G.Plane)
34 drawEnemy character = (entityCoords character, G.color G.Red G.Vivid $ G.cell 'X')
36 drawBorder :: (G.Coords, G.Plane)
38 let outerBorder = G.box '%' (fst bottomRightBoundary) (snd bottomRightBoundary)
39 let innerBlank = bimap (+ 1) (+ 1) topLeftBoundary G.% G.box ' ' (fst bottomRightBoundary - 2) (snd bottomRightBoundary - 2)
40 (topLeftBoundary, outerBorder G.& innerBlank)
42 drawScore :: State -> (G.Coords, G.Plane)
43 drawScore state = ((snd bottomRightBoundary, fst topLeftBoundary), G.stringPlane $ "Score: " <> show (stateScore state))
45 drawTitle :: (G.Coords, G.Plane)
46 drawTitle = (bimap (flip (-) 3) (flip (-) 6) centreCoords, G.stringPlane "==AVOIDANCE==\n\n\nMove: WASD\nHelp: H\nPlay: P\nQuit: Q")
48 drawHelp :: (G.Coords, G.Plane)
49 drawHelp = (topLeftBoundary, G.textBox "Use W, A, S, and D to move up, left, down, and right respectively.\nPress Q during an active game to return to the main menu.\nPress Q while on the main menu to terminate the application.\n\nOBJECTIVE:\n\nPush the box around the screen, making sure that it is not pushed off the edge.\nBox thieves will appear sporadically to steal the box.\nYour objective is to keep the box on-screen for as long as possible.\n\n P: Player\n O: Box\n X: Box Thief\n\nPress any key to return to the main menu." (fst bottomRightBoundary) (snd bottomRightBoundary))
52 drawBlank = G.blankPlane (fst bottomRightBoundary) (snd bottomRightBoundary)
54 data State = State { statePlayer :: !(Character Player)
55 , stateBox :: !(Character Box)
56 , stateEnemy :: ![Character Enemy]
57 , stateDifficulty :: !Int
58 , stateIsQuitting :: !Bool
59 , stateRandomGen :: !G.StdGen
60 , stateScreen :: !Screen
64 gameSettings :: G.StdGen -> G.Game State
65 gameSettings stdgen = G.Game { G.gScreenWidth = fst bottomRightBoundary
66 , G.gScreenHeight = snd bottomRightBoundary
68 , G.gInitState = initState stdgen
69 , G.gLogicFunction = handleEvent
70 , G.gDrawFunction = render
71 , G.gQuitFunction = shouldQuit
74 initState :: G.StdGen -> State
75 initState stdgen = State { statePlayer = initPlayer
77 , stateEnemy = initEnemies
79 , stateIsQuitting = False
80 , stateRandomGen = stdgen
81 , stateScreen = TitleScreen
85 centreCoords :: G.Coords
86 centreCoords = (centreCoord (snd topLeftBoundary) (snd bottomRightBoundary), centreCoord (fst topLeftBoundary) (fst bottomRightBoundary))
88 centreCoord lower upper = (+) lower $ flip div 2 $ upper - lower
90 initPlayer :: Character Player
91 initPlayer = Character { entityCoords = centreCoords, entityDirection = Nothing }
93 initBox :: Character Box
94 initBox = Character { entityCoords = limitCoords $ bimap (+ 1) (+ 1) centreCoords, entityDirection = Nothing }
96 initEnemies :: [Character Enemy]
99 handleEvent :: State -> G.Event -> State
100 handleEvent state (G.KeyPress key) = handleKeyPress state $ toUpper key
101 handleEvent state G.Tick = handleTick state
103 handleTick :: State -> State
104 handleTick state@(State { stateScreen = GameScreen }) = do
105 let stateAfterEnemies = handleEnemies state
106 let oldPlayer = statePlayer state
107 let player = moveCharacter (entityDirection oldPlayer) oldPlayer
108 let box = handleCollision player $ handleCollisions (stateEnemy stateAfterEnemies) (stateBox stateAfterEnemies)
109 let newScore = stateScore stateAfterEnemies + 1
110 if isOutOfBounds box then stateAfterEnemies { stateScreen = TitleScreen }
111 else stateAfterEnemies { statePlayer = resetDirection player, stateBox = box, stateScore = newScore, stateDifficulty = div newScore 200 + 1 }
112 handleTick state = state
114 handleEnemies :: State -> State
115 handleEnemies state = do
116 let oldEnemies = stateEnemy state
117 let randomGen1 = stateRandomGen state
118 let maxEnemies = stateDifficulty state
119 let (newEnemies, randomGen2) = flip S.runState (stateRandomGen state) $ randomRange (0, maxEnemies - length oldEnemies) >>= flip createEnemies oldEnemies
120 let updatedEnemies = moveEnemies newEnemies
121 state { stateEnemy = updatedEnemies, stateRandomGen = randomGen2 }
123 moveEnemies :: [Character Enemy] -> [Character Enemy]
124 moveEnemies = filter (not . isOutOfBounds) . fmap (\character -> moveCharacter (entityDirection character) character)
126 createEnemies :: Int -> [Character Enemy] -> RandomState [Character Enemy]
127 createEnemies 0 enemies = return enemies
128 createEnemies enemiesToCreate enemies = createEnemy >>= createEnemies (enemiesToCreate - 1) . flip (:) enemies
130 createEnemy :: RandomState (Character Enemy)
132 randomDirection <- randomResult
133 position <- enemyStartPosition randomDirection
134 return $ Character { entityCoords = position, entityDirection = Just randomDirection }
136 randomAction :: (Random a) => (G.StdGen -> (a, G.StdGen)) -> RandomState a
137 randomAction action = do
139 let (result, randomGenNew) = action randomGen
143 randomResult :: (Random a) => RandomState a
144 randomResult = randomAction random
146 randomRange :: (Random a) => (a, a) -> RandomState a
147 randomRange bounds = randomAction $ G.getRandom bounds
149 enemyStartPosition :: Direction -> RandomState G.Coords
150 enemyStartPosition U = randomRange (fst topLeftBoundary, fst bottomRightBoundary) >>= \column -> return (snd bottomRightBoundary, column)
151 enemyStartPosition D = randomRange (fst topLeftBoundary, fst bottomRightBoundary) >>= \column -> return (snd topLeftBoundary, column)
152 enemyStartPosition L = randomRange (snd topLeftBoundary, snd bottomRightBoundary) >>= \row -> return (row, fst bottomRightBoundary)
153 enemyStartPosition R = randomRange (snd topLeftBoundary, snd bottomRightBoundary) >>= \row -> return (row, fst topLeftBoundary)
155 handleKeyPress :: State -> Char -> State
156 handleKeyPress state@(State { stateScreen = TitleScreen }) 'Q' = state { stateIsQuitting = True }
157 handleKeyPress state@(State { stateScreen = TitleScreen }) 'P' = state { statePlayer = initPlayer, stateBox = initBox, stateEnemy = initEnemies, stateDifficulty = 1, stateScreen = GameScreen, stateScore = 0 }
158 handleKeyPress state@(State { stateScreen = TitleScreen }) 'H' = state { stateScreen = HelpScreen }
159 handleKeyPress state@(State { stateScreen = HelpScreen }) _ = state { stateScreen = TitleScreen }
160 handleKeyPress state@(State { stateScreen = GameScreen }) 'Q' = state { stateScreen = TitleScreen }
161 handleKeyPress state@(State { stateScreen = GameScreen }) 'W' = state { statePlayer = updateDirection U (statePlayer state) }
162 handleKeyPress state@(State { stateScreen = GameScreen }) 'S' = state { statePlayer = updateDirection D (statePlayer state) }
163 handleKeyPress state@(State { stateScreen = GameScreen }) 'A' = state { statePlayer = updateDirection L (statePlayer state) }
164 handleKeyPress state@(State { stateScreen = GameScreen }) 'D' = state { statePlayer = updateDirection R (statePlayer state) }
165 handleKeyPress state _ = state
167 render :: State -> G.Plane
168 render state@(State { stateScreen = TitleScreen }) = G.mergePlanes drawBlank $ drawTitle : drawScore state : []
169 render state@(State { stateScreen = HelpScreen }) = G.mergePlanes drawBlank $ drawHelp : drawScore state : []
170 render state@(State { stateScreen = GameScreen }) = do
171 let playerPlane = drawPlayer $ statePlayer state
172 let boxPlane = drawBox $ stateBox state
173 let enemyPlanes = drawEnemy <$> stateEnemy state
174 G.mergePlanes drawBlank $ drawBorder : drawScore state : playerPlane : boxPlane : enemyPlanes
176 shouldQuit :: State -> Bool
177 shouldQuit = stateIsQuitting
179 boundaries :: (G.Coords, G.Coords)
180 boundaries = ((1, 1), (80, 24))
182 topLeftBoundary :: G.Coords
183 topLeftBoundary = fst boundaries
185 bottomRightBoundary :: G.Coords
186 bottomRightBoundary = snd boundaries
188 resetDirection :: Character Player -> Character Player
189 resetDirection character = character { entityDirection = Nothing }
191 updateDirection :: Direction -> Character Player -> Character Player
192 updateDirection direction character = character { entityDirection = Just direction }
194 moveCharacter :: Maybe Direction -> Character t -> Character t
195 moveCharacter (Just U) character = updatePosition (-1, 0) character
196 moveCharacter (Just D) character = updatePosition (1, 0) character
197 moveCharacter (Just L) character = updatePosition (0, -1) character
198 moveCharacter (Just R) character = updatePosition (0, 1) character
199 moveCharacter _ character = character
201 limitCoords :: G.Coords -> G.Coords
202 limitCoords (a, b) = (limitCoord snd a, limitCoord fst b)
204 limitCoord projection a
205 | a < projection topLeftBoundary = projection topLeftBoundary
206 | a > projection bottomRightBoundary = projection bottomRightBoundary
209 updatePosition :: G.Coords -> Character t -> Character t
210 updatePosition (velocityRow, velocityColumn) character = do
211 let (oldRow, oldColumn) = entityCoords character
212 let newCoords = (oldRow + velocityRow, oldColumn + velocityColumn)
213 let boundedCoords = limitCoords newCoords
214 character { entityCoords = boundedCoords }
216 willCollide :: Maybe Direction -> G.Coords -> G.Coords -> Bool
217 willCollide Nothing _ _ = False
218 willCollide _ first second = first == second
220 handleCollisions :: [Character Enemy] -> Character Box -> Character Box
221 handleCollisions enemies box = do
222 let matchingEnemy = listToMaybe $ filter (\enemy -> willCollide (entityDirection enemy) (entityCoords enemy) (entityCoords box)) enemies
223 case matchingEnemy of
225 Just enemy -> moveCharacter (entityDirection enemy) box
227 handleCollision :: Character t -> Character Box -> Character Box
228 handleCollision (Character { entityCoords = coords, entityDirection = direction }) box
229 | willCollide direction coords (entityCoords box) = moveCharacter direction box
232 isOutOfBounds :: Character t -> Bool
233 isOutOfBounds (Character { entityCoords = (row, column) }) = row == fst topLeftBoundary || column == snd topLeftBoundary || row == snd bottomRightBoundary || column == fst bottomRightBoundary