holmes-0.3.2.0: examples/WaveFunctionCollapse.hs
{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE ViewPatterns #-}
module WaveFunctionCollapse where
import Data.Function ((&))
import Data.Hashable (Hashable)
import Data.Holmes
import Data.JoinSemilattice.Intersect (fromList, toList)
import Data.List (transpose)
import Data.List.Split (chunksOf)
import Data.Maybe (isJust, mapMaybe)
import GHC.Generics (Generic)
import Relude ((!!?))
import Test.Hspec (Spec, it, shouldBe)
-- Wave function collapse* is an algorithm that works by placing constraints
-- between each cell and their neighbours. A cell is randomly specialised to a
-- particular value, and the effects ripple out via the constraints. Then,
-- another cell is specialised, and the process repeats until all cells are
-- specialised.
--
-- It turns out that this is actually just a special case of the propagator
-- idea, and specifically the `Intersect` strategy. While we're not going to
-- implement the full algorithm here, we'll demonstrate the idea with a
-- simplified version in order to draw some desert island maps!
--
-- * https://github.com/mxgmn/WaveFunctionCollapse
--------------------------------------------------
-- First, we'll start with a type to specify the possible terrain types in our
-- map:
data Tile = Water | Sand | Grass | Tree
deriving stock (Eq, Ord, Bounded, Enum, Generic)
deriving anyclass (Hashable)
instance Show Tile where
show = \case
Water -> "💦"
Sand -> "🔅"
Grass -> "🍀"
Tree -> "🌲"
-- Now, we'll specify some constraints on our neighbours. Again, this is a very
-- simplified version of the WaveFunctionCollapse concept - typically, we'd
-- have far more "tiles", and neighbours would be chosen by properties attached
-- to each edge of each tile.
surroundings :: Tile -> Intersect Tile
surroundings = fromList . \case
-- A tree must be entirely surrounded by grass. Two trees cannot touch, and
-- trees cannot be on beaches or in water.
Tree -> [ Grass ]
-- The only thing that can neighbour water is more water or sand. This means
-- that every island has a beach, and we might even get some small islands
-- out in water, too!
Water -> [ Sand, Water ]
-- Sand must sit between water and grass. Note that this simple system
-- doesn't prevent random sand tiles amid grass; we'd need to specify the
-- constraints in a more comprehensive way to mitigate this.
Sand -> [ Sand, Water, Grass ]
-- Grass can neighbour sand, more grass, or trees!
Grass -> [ Sand, Tree, Grass ]
-- Get the neighbours of a cell at a given index.
neighbours :: Int -> [ x ] -> [ x ]
neighbours index board = mapMaybe (board !!?)
[ index - 21, index - 20, index - 19
, index - 1, {- HOME! -} index + 1
, index + 19, index + 20, index + 21
]
-- The 20 × 20 board makes up 400 tiles.
tiles :: Config Holmes (Intersect Tile)
tiles = shuffle (400 `from` [ Water, Sand, Grass, Tree ])
--------------------------------------------------
maps :: IO (Maybe [ Intersect Tile ])
maps = do
tiles `satisfying` \board@(chunksOf 20 -> rows) -> do
let columns = transpose rows
and'
[ -- As we're trying to draw an island, we'll surround the whole map with
-- water:
all' (.== lift Water) (head rows)
, all' (.== lift Water) (last rows)
, all' (.== lift Water) (head columns)
, all' (.== lift Water) (last columns)
-- To generate more interesting maps, we'll require that every valid
-- map contains at least one tree (and thus has at least one 5 × 5
-- island).
, any' (.== lift Tree) board
-- For each tile, find the valid surrounding tiles, then constraint its
-- neighbours to those possibilities.
, board & allWithIndex' \index tile -> do
let candidates = tile .>>= surroundings
all' (.== candidates) (neighbours index board)
]
-- If you want to see some of the generated maps, run `cabal new-repl examples`
-- and use the following function to print out a result:
--
-- > import WaveFunctionCollapse
-- > Just example <- maps
-- > printMap example
printMap :: [ Intersect Tile ] -> IO ()
printMap (chunksOf 20 -> rows) = mapM_ printRow rows
where printRow = putStrLn . foldMap (show . head . toList)
-- Use `cabal new-test examples` to run these tests and check for correct
-- solutions.
spec_wfc :: Spec
spec_wfc = it "generates a map" do
maps >>= \result -> isJust result `shouldBe` True