{-| Module : Game.H2048.Core Copyright : (c) 2014 Javran Cheng License : MIT Maintainer : Javran.C@gmail.com Stability : experimental Portability : POSIX The core game logic implementation for Game 2048. The routine for using this library would be: 1. use `initGameBoard` to get a valid board to begin with. (two new cells are inserted for you, if you want to use an empty board, `initBoard` is a shorthand) 2. interact with user / algorithm / etc., use `updateBoard` to update a board. 3. use `insertNewCell` to insert a new cell randomly 4. examine if the player wins / loses / is still alive using `gameState`. -} {-# LANGUAGE TupleSections, FlexibleContexts, RankNTypes #-} module Game.H2048.Core ( Dir (..) , BoardUpdateResult , Board , mkBoard , fromBoard , defBoard , Line , mkLine , defLine , gameState , GameState(..) , compactLine' , compactLine , initGameBoard , updateBoard , insertNewCell , nextMoves ) where import Data.List import Data.Maybe import Control.Applicative import Control.Monad.Writer import Control.Monad.Random import Data.Coerce import Control.Lens import Game.H2048.Utils -- | represent a 4x4 board for Game 2048 -- each element should be either zero or 2^i -- where i >= 1. newtype Board = Board [Line] data GameState = GS { hasWon :: Bool , isAlive :: Bool } deriving (Eq, Show) {- TODO: I think the problem with current Board & Line is that it is too fancy and in some sense restrictive for the work it needs to do - all this transposing and isomorphism stuff looks nice at first but using lens is really an overkill and we don't really have many performance gain to begin with. regarding the "restrictive" bit: Line is really just a list of four elements and this makes it hard to extend to say 5x5 or non-square board. What I want to try is to use a Map structure instead: - when collapsing a line, the sequence of coordinates are computed to extract the line from board, process as if it is a list of cells and write it back afterwards. Also I want to try out another idea: define `newtype Cell = Cell Int`, where `merge :: Cell -> Cell -> Maybe Cell` merges two cell only if two embedding numbers are same (say i) and the result is `Just (Cell (i+1))` this saves a little bit space and allows us to use stuff other than 1,2,4,8... -} mkBoard :: [[Int]] -> Board mkBoard = Board . take 4 . (++ repeat defLine) . (mkLine <$>) fromBoard :: Board -> [[Int]] fromBoard = coerce -- | a list of 4 elements, stands for -- one column / row in the board {- Alternatively Line could be a ~ Int => (a,a,a,a) and Board being a ~ Line -> (a,a,a,a), we should be able to utilize fixed-vector. but here we don't have a user-friendly set of tools to deal with traversal (at least not that I'm aware of), so I guess for now we can still stick to List. -} newtype Line = Line [Int] deriving (Eq, Show) mkLine :: [Int] -> Line mkLine = Line . take 4 . (++ repeat 0) defLine :: Line defLine = mkLine [] -- | result after a successful 'updateBoard' type BoardUpdateResult = (Board, Int) {- notice that we don't need GameState at all: - at any point in time, the game is won when we have a cell that contains a value >= 2048 - when there are possible moves, we are not done yet, otherwise the game is lost. therefore we could probably return a list of pairs of (, (Sum Int, Line) compactLine' (Line l) = mkLine <$> merge (filter (/= 0) l) where merge r = case r of (x:y:xs) -> if x == y -- only place where score are collected. then -- try to merge first two elements, -- and process rest of it. let s = x+y in tell (Sum s) >> (s:) <$> merge xs else -- just skip the first one, -- and process rest of it. (x:) <$> merge (y:xs) _ -> pure r -- | move each non-zero element to their leftmost possible -- position while preserving the order compactLine :: MonadWriter (Sum Int) m => Line -> m Line compactLine l = let (v, l') = compactLine' l in writer (l', v) -- | update the board taking a direction, -- a 'BoardUpdated' is returned on success, -- if this update does nothing, that means a failure (Nothing) -- note that here "update" does not include adding one random cell of 2 or 4 -- into the board updateBoard :: Dir -> Board -> Maybe BoardUpdateResult updateBoard d (Board board) = do guard $ board /= board' pure (Board board', getSum score) where -- transform boards so that -- we only focus on "gravitize to the left". -- and convert back after the gravitization is done. (board',score) = runWriter $ withIso (getIso d) $ \g f -> g <$> mapM compactLine (f board) getIso :: Dir -> Iso' [Line] [Line] getIso d = c . ik . from c where ik = case d of DLeft -> id DRight -> sRight DUp -> sUp DDown -> sRight . sUp c :: Iso' [Line] [[Int]] c = coerced sRight = involuted (map reverse) sUp = involuted transpose nextMoves :: Board -> [(Dir, BoardUpdateResult)] nextMoves b = mapMaybe (\d -> (d,) <$> updateBoard d b) allDirs -- | find blank cells in a board, -- return coordinates for each blank cell blankCells :: Board -> [(Int, Int)] blankCells (Board b) = map (\(row, (col, _)) -> (row,col)) blankCells' where {- the algorithm is to just find all empty cells - we could of course keep track of all empty cells, but that will be overcomplicated and hard to maintain when we do "compactLine" -} blankCells' = filter ((== 0) . snd . snd) linearBoard -- flatten to make it ready for filter linearBoard = concat $ zipWith tagRow [0..] colTagged -- tag cells with row num tagRow row = map (row,) -- tag cells with column num colTagged = map (zip [0..] . (coerce :: Line -> [Int])) b gameState :: Board -> GameState gameState nb@(Board b) = GS hw alv where hw = (any (>= 2048) . concatMap (coerce :: Line -> [Int])) b alv = not . null . nextMoves $ nb -- | initialize the board by puting two cells randomly -- into the board. -- See 'generateNewCell' for the cell generating rule. initGameBoard :: (MonadRandom m, Alternative m) => m (Board, Int) initGameBoard = -- insert two cells and return the resulting board -- here we can safely assume that the board has at least two empty cells -- so that we can never have Nothing on the LHS (,0) . fromJust <$> (insertNewCell defBoard >>= (insertNewCell . fromJust)) -- | try to insert a new cell randomly insertNewCell :: (MonadRandom r, Alternative r) => Board -> r (Maybe Board) insertNewCell b = do -- get a list of coordinates of blank cells let availableCells = blankCells b guard $ (not . null) availableCells -- randomly pick up an available cell by choosing index choice <- getRandomR (0, length availableCells - 1) let (row,col) = availableCells !! choice value <- generateNewCell let (Board b') = b c1 :: ([Int] -> [Int]) -> Line -> Line c1 = coerce pure $ Just $ Board $ (inPos row . c1 . inPos col) (const value) b' -- | generate a new cell according to the game rule -- we have 90% probability of getting a cell of value 2, -- and 10% probability of getting a cell of value 4. generateNewCell :: (MonadRandom r) => r Int generateNewCell = getRandom >>= \r -> pure $ if r < (0.9 :: Float) then 2 else 4