333 lines
9.7 KiB
Haskell
333 lines
9.7 KiB
Haskell
{-# LANGUAGE FlexibleInstances #-}
|
|
{-# LANGUAGE LambdaCase #-}
|
|
{-# LANGUAGE RankNTypes #-}
|
|
{-# LANGUAGE TemplateHaskell #-}
|
|
module Tetris
|
|
(
|
|
-- Game state modifiers
|
|
initGame
|
|
, timeStep
|
|
, shift
|
|
, rotate
|
|
, hardDrop
|
|
-- Game state handlers
|
|
, execTetris
|
|
, evalTetris
|
|
-- Game state queries
|
|
, isGameOver
|
|
, hardDroppedBlock
|
|
, coords
|
|
-- Types
|
|
, Block(..)
|
|
, Coord
|
|
, Direction(..)
|
|
, Game(..)
|
|
, Tetrimino(..)
|
|
, Tetris
|
|
-- Lenses
|
|
, block, board, level, nextShape, score, shape
|
|
-- Constants
|
|
, boardHeight, boardWidth, relCells
|
|
) where
|
|
|
|
import Prelude hiding (Left, Right)
|
|
import Control.Applicative ((<|>))
|
|
import Control.Monad (forM_, mfilter, when, (<=<))
|
|
import Control.Monad.IO.Class (MonadIO(..), liftIO)
|
|
|
|
import Control.Monad.Trans.State (StateT(..), gets, evalStateT, execStateT)
|
|
import Data.Map (Map)
|
|
import qualified Data.Map as M
|
|
import Data.Sequence (Seq(..), (><))
|
|
import qualified Data.Sequence as Seq
|
|
import Control.Lens hiding (Empty)
|
|
import Linear.V2 (V2(..), _y)
|
|
import qualified Linear.V2 as LV
|
|
import System.Random (getStdRandom, randomR)
|
|
-- Types and instances
|
|
|
|
-- | Tetris shape types
|
|
data Tetrimino = I | O | T | S | Z | J | L
|
|
deriving (Eq, Show, Enum)
|
|
|
|
-- | Coordinates
|
|
type Coord = V2 Int
|
|
|
|
-- | Tetris shape in location context
|
|
data Block = Block
|
|
{ _shape :: Tetrimino -- ^ block type
|
|
, _origin :: Coord -- ^ origin
|
|
, _extra :: [Coord] -- ^ extraneous cells
|
|
} deriving (Eq, Show)
|
|
|
|
makeLenses ''Block
|
|
|
|
data Direction = Left | Right | Down
|
|
deriving (Eq, Show)
|
|
|
|
-- | Board
|
|
--
|
|
-- If coordinate not present in map, yet in bounds, then it is empty,
|
|
-- otherwise its value is the type of tetrimino occupying it.
|
|
type Board = Map Coord Tetrimino
|
|
|
|
-- | Game state
|
|
data Game = Game
|
|
{ _level :: Int
|
|
, _block :: Block
|
|
, _nextShape :: Tetrimino
|
|
, _nextShapeBag :: Seq.Seq Tetrimino
|
|
, _rowClears :: Seq.Seq Int
|
|
, _score :: Int
|
|
, _board :: Board
|
|
} deriving (Eq, Show)
|
|
makeLenses ''Game
|
|
|
|
type TetrisT = StateT Game
|
|
type Tetris a = forall m. (Monad m) => TetrisT m a
|
|
|
|
evalTetris :: Tetris a -> Game -> a
|
|
evalTetris m = runIdentity . evalStateT m
|
|
|
|
execTetris :: Tetris a -> Game -> Game
|
|
execTetris m = runIdentity . execStateT m
|
|
|
|
-- Translate class for direct translations, without concern for boundaries
|
|
-- 'shift' concerns safe translations with boundaries
|
|
class Translatable s where
|
|
translate :: Direction -> s -> s
|
|
translate = translateBy 1
|
|
translateBy :: Int -> Direction -> s -> s
|
|
|
|
instance Translatable Coord where
|
|
translateBy n Left (V2 x y) = V2 (x-n) y
|
|
translateBy n Right (V2 x y) = V2 (x+n) y
|
|
translateBy n Down (V2 x y) = V2 x (y-n)
|
|
|
|
instance Translatable Block where
|
|
translateBy n d b =
|
|
b & origin %~ translateBy n d
|
|
& extra %~ fmap (translateBy n d)
|
|
|
|
-- Low level functions on blocks and coordinates
|
|
|
|
initBlock :: Tetrimino -> Block
|
|
initBlock t = Block t startOrigin . fmap (+ startOrigin) . relCells $ t
|
|
|
|
relCells :: Tetrimino -> [Coord]
|
|
relCells I = map v2 [(-2, 0), (-1, 0), (1, 0)]
|
|
relCells O = map v2 [(-1, 0), (-1, -1), (0, -1)]
|
|
relCells S = map v2 [(-1, -1), (0, -1), (1, 0)]
|
|
relCells Z = map v2 [(-1, 0), (0, -1), (1, -1)]
|
|
relCells L = map v2 [(-1, -1), (-1, 0), (1, 0)]
|
|
relCells J = map v2 [(-1, 0), (1, 0), (1, -1)]
|
|
relCells T = map v2 [(-1, 0), (0, -1), (1, 0)]
|
|
|
|
-- | Visible, active board size
|
|
boardWidth, boardHeight :: Int
|
|
boardWidth = 10
|
|
boardHeight = 20
|
|
|
|
-- | Starting block origin
|
|
startOrigin :: Coord
|
|
startOrigin = V2 6 22
|
|
|
|
-- | Rotate block counter clockwise about origin
|
|
-- *Note*: Strict unsafe rotation not respecting boundaries
|
|
-- Safety can only be assured within Game context
|
|
rotateRaw :: Block -> Block
|
|
rotateRaw b@(Block s o@(V2 xo yo) cs)
|
|
| -- O doesn't need rotation
|
|
s == O = b
|
|
| -- I only has two orientations
|
|
s == I && V2 xo (yo + 1) `elem` cs = rotateWith clockwise
|
|
| otherwise = rotateWith counterclockwise
|
|
where
|
|
clockwise = (+ o) . cwperp . subtract o
|
|
counterclockwise = (+ o) . LV.perp . subtract o
|
|
rotateWith dir = b & extra %~ fmap dir
|
|
cwperp (V2 x y) = V2 y (-x)
|
|
|
|
-- | Get coordinates of entire block
|
|
coords :: Block -> [Coord]
|
|
coords b = b ^. origin : b ^. extra
|
|
|
|
-- Higher level functions on game and board
|
|
|
|
-- | Facilitates cycling through at least 4 occurences of each shape
|
|
-- before next bag (random permutation of 4*each tetrimino) is created. If input is empty,
|
|
-- generates new bag, otherwise just unshifts the first value and returns pair.
|
|
bagFourTetriminoEach :: Seq.Seq Tetrimino -> IO (Tetrimino, Seq.Seq Tetrimino)
|
|
bagFourTetriminoEach (t :<| ts) = pure (t, ts)
|
|
bagFourTetriminoEach Empty =
|
|
bagFourTetriminoEach <=< shuffle . Seq.fromList . take 28 $ cycle [I ..]
|
|
|
|
-- | Initialize a game with a given level
|
|
initGame :: Int -> IO Game
|
|
initGame lvl = do
|
|
(s1, bag1) <- bagFourTetriminoEach mempty
|
|
(s2, bag2) <- bagFourTetriminoEach bag1
|
|
pure $ Game
|
|
{ _level = lvl
|
|
, _block = initBlock s1
|
|
, _nextShape = s2
|
|
, _nextShapeBag = bag2
|
|
, _score = 0
|
|
, _rowClears = mempty
|
|
, _board = mempty
|
|
}
|
|
|
|
isGameOver :: Game -> Bool
|
|
isGameOver g = blockStopped g && g ^. (block . origin) == startOrigin
|
|
|
|
-- | The main game execution, this is executed at each discrete time step
|
|
timeStep :: MonadIO m => TetrisT m ()
|
|
timeStep = do
|
|
gets blockStopped >>= \case
|
|
False -> gravitate
|
|
True -> do
|
|
freezeBlock
|
|
clearFullRows >>= addToRowClears
|
|
updateScore
|
|
nextBlock
|
|
|
|
-- | Gravitate current block, i.e. shift down
|
|
gravitate :: Tetris ()
|
|
gravitate = shift Down
|
|
|
|
-- | If necessary: clear full rows and return the count
|
|
clearFullRows :: Tetris Int
|
|
clearFullRows = do
|
|
brd <- use board
|
|
let rowSize r = length $ M.filterWithKey (\(V2 _ y) _ -> r == y) brd
|
|
fullRows = filter (\r -> boardWidth == rowSize r) [1 .. boardHeight]
|
|
-- Clear cells in full rows
|
|
modifying board $ M.filterWithKey $ \(V2 _ y) _ -> y `notElem` fullRows
|
|
-- Shift cells above full rows
|
|
modifying board $ M.mapKeysMonotonic $ over _y $ \y ->
|
|
y - length (filter (< y) fullRows)
|
|
return $ length fullRows
|
|
|
|
-- | Empties row on 0, otherwise appends value (just keeps consecutive information)
|
|
addToRowClears :: Int -> Tetris ()
|
|
addToRowClears 0 = rowClears .= mempty
|
|
addToRowClears n = rowClears %= (|> n)
|
|
|
|
-- | This updates game points with respect to the current
|
|
-- _rowClears value (thus should only be used ONCE per step)
|
|
--
|
|
-- Note I'm keeping rowClears as a sequence in case I want to award
|
|
-- more points for back to back clears, right now the scoring is more simple,
|
|
-- but you do get more points for more rows cleared at once.
|
|
updateScore :: Tetris ()
|
|
updateScore = do
|
|
multiplier <- (1 +) <$> use level
|
|
clears <- latestOrZero <$> use rowClears
|
|
let newPoints = multiplier * points clears
|
|
score %= (+ newPoints)
|
|
where
|
|
-- Translate row clears to points
|
|
points 0 = 0
|
|
points 1 = 40
|
|
points 2 = 100
|
|
points 3 = 300
|
|
points _ = 800
|
|
-- | Get last value of sequence or 0 if empty
|
|
latestOrZero :: Seq.Seq Int -> Int
|
|
latestOrZero Empty = 0
|
|
latestOrZero (_ :|> n) = n
|
|
|
|
-- | Handle counterclockwise block rotation (if possible)
|
|
-- Allows wallkicks: http://tetris.wikia.com/wiki/TGM_rotation
|
|
rotate :: Tetris ()
|
|
rotate = do
|
|
blk <- use block
|
|
brd <- use board
|
|
let mblk = foldr (<|>) Nothing
|
|
$ mfilter (isValidBlockPosition brd)
|
|
. pure
|
|
. ($ blk)
|
|
<$> [ rotateRaw
|
|
, rotateRaw . translate Left
|
|
, rotateRaw . translate Right
|
|
]
|
|
forM_ mblk $ assign block
|
|
|
|
blockStopped :: Game -> Bool
|
|
blockStopped g = isStopped (g ^. board) (g ^. block)
|
|
|
|
-- | Check if a block on a board is stopped from further gravitation
|
|
isStopped :: Board -> Block -> Bool
|
|
isStopped brd = any stopped . coords
|
|
where
|
|
stopped = (||) <$> atBottom <*> (`M.member` brd) . translate Down
|
|
atBottom = (== 1) . view _y
|
|
|
|
hardDrop :: Tetris ()
|
|
hardDrop = hardDroppedBlock >>= assign block
|
|
|
|
hardDroppedBlock :: Tetris Block
|
|
hardDroppedBlock = do
|
|
boardCoords <- M.keys <$> use board
|
|
blockCoords <- coords <$> use block
|
|
let diffs =
|
|
[ y - yo
|
|
| (V2 xo yo) <- boardCoords
|
|
, (V2 x y ) <- blockCoords
|
|
, xo == x
|
|
, yo < y
|
|
]
|
|
minY = minimum $ view _y <$> blockCoords
|
|
dist = minimum $ subtract 1 <$> (minY : diffs)
|
|
translateBy dist Down <$> use block
|
|
|
|
-- | Freeze current block
|
|
freezeBlock :: Tetris ()
|
|
freezeBlock = do
|
|
blk <- use block
|
|
modifying board $ M.union $ M.fromList [ (c, _shape blk) | c <- coords blk ]
|
|
|
|
-- | Replace block with next block
|
|
nextBlock :: MonadIO m => TetrisT m ()
|
|
nextBlock = do
|
|
bag <- use nextShapeBag
|
|
(t, ts) <- liftIO $ bagFourTetriminoEach bag
|
|
use nextShape >>= \s -> block .= initBlock s
|
|
nextShape .= t
|
|
nextShapeBag .= ts
|
|
|
|
-- | Try to shift current block; if shifting not possible, leave block where it is
|
|
shift :: Direction -> Tetris ()
|
|
shift dir = do
|
|
brd <- use board
|
|
blk <- use block
|
|
let candidate = translate dir blk
|
|
when (isValidBlockPosition brd candidate) $
|
|
block .= candidate
|
|
|
|
-- | Check if coordinate is already occupied or free in board
|
|
isFree :: Board -> Coord -> Bool
|
|
isFree = flip M.notMember
|
|
|
|
-- | Check if coordinate is in or out of bounds
|
|
isInBounds :: Coord -> Bool
|
|
isInBounds (V2 x y) = 1 <= x && x <= boardWidth && 1 <= y
|
|
|
|
-- | Checks if block's potential new location is valid
|
|
isValidBlockPosition :: Board -> Block -> Bool
|
|
isValidBlockPosition brd = all validCoord . coords
|
|
where validCoord = (&&) <$> isFree brd <*> isInBounds
|
|
|
|
-- General utilities
|
|
|
|
-- | Shuffle a sequence (random permutation)
|
|
shuffle :: Seq.Seq a -> IO (Seq.Seq a)
|
|
shuffle xs
|
|
| null xs = mempty
|
|
| otherwise = do
|
|
randomPosition <- getStdRandom (randomR (0, length xs - 1))
|
|
let (left, y :<| ys) = Seq.splitAt randomPosition xs
|
|
fmap (y <|) (shuffle $ left >< ys)
|
|
|
|
v2 :: (a, a) -> V2 a
|
|
v2 (x, y) = V2 x y
|