Inspired by Brent Yorgey's adventure game, I've been writing a small text based adventure game (a la Zork) that uses the MonadPrompt library. It's been fairly straightforward to use it to separate the IO backend from the actual function that governs gameplay, but I'm now trying to do something a bit more complicated with it.
Basically, I want to enable undo and redo as a feature of the game. My strategy for this has been to keep a zipper of gamestates (which includes what the last input was). Since I want to be able to maintain history when reloading the game, The save file is just a list of all inputs the player performed that can affect the gamestate (so examining the inventory would not be included, say). The idea is to quickly replay the last game from the inputs in the save file when loading a game (skipping output to the terminal, and taking input from the list in the file), and thereby build up the full history of gamestates.
Here is some code that shows basically the setup I have (I apologize for the length, but this is much simplified from the actual code):
data Action = UndoAction | RedoAction | Go Direction -- etc ...
-- Actions are what we parse user input into, there is also error handling
-- that I left out of this example
data RPGPrompt a where
Say :: String -> RPGPrompt ()
QueryUser :: String -> RPGPrompt Action
Undo :: RPGPrompt ( Prompt RPGPrompt ())
Redo :: RPGPrompt ( Prompt RPGPrompt ())
{-
... More prompts like save, quit etc. Also a prompt for the play function
to query the underlying gamestate (but not the GameZipper directly)
-}
data GameState = GameState { {- hp, location etc -} }
data GameZipper = GameZipper { past :: [GameState],
present :: GameState,
future :: [GameState]}
play :: Prompt RPGPrompt ()
play = do
a <- prompt (QueryUser "What do you want to do?")
case a of
Go dir -> {- modify gamestate to change location ... -} >> play
UndoAction -> prompt (Say "Undo!") >> join (prompt Undo)
...
parseAction :: String -> Action
...
undo :: GameZipper -> GameZipper
-- shifts the last state to the present state and the current state to the future
basicIO :: RPGPrompt a -> StateT GameZipper IO a
basicIO (Say x) = putStrLn x
basicIO (QueryUser query) = do
putStrLn query
r <- parseAction <$> getLine
case r of
UndoAction -> {- ... check if undo is possible etc -}
Go dir -> {- ... push old gamestate into past in gamezipper,
create fresh gamestate for present ... -} >> return r
...
basicIO (Undo) = modify undo >> return play
...
Next is the replayIO function. It takes a backend function to execute when it's done replaying (usually basicIO ) and a list of actions to replay
replayIO :: (RPGPrompt a -> StateT GameZipper IO a) ->
[Action] ->
RPGPrompt a ->
StateT GameZipper IO a
replayIO _ _ (Say _) = return () -- don't output anything
replayIO resume [] (QueryUser t) = resume (QueryUser t)
replayIO _ (action:actions) (Query _) =
case action of
... {- similar to basicIO here, but any non-gamestate-affecting
actions are no-ops (though the save file shouldn't record them
technically) -}
...
This implementation of replayIO
doesn't work though, because replayIO
isn't
directly recursive, you can't actually remove Actions from the list of actions
passed to replayIO
. It gets an initial list of actions from the function that
loads the save file, and then it can peek at the first action in the list.
There only solution that has occurred to me so far is to maintain the list of
replay actions inside the GameState
. I don't like this because it means I can't
cleanly separate basicIO
and replayIO
. I'd like for replayIO
to handle its
action list, and then when it passes control to basicIO
for that list to
disappear entirely.
So far, I've used runPromptM
from the MonadPrompt package to use the Prompt
monad, but looking through the package, the runPromptC and runRecPromptC
functions look like they are much more powerful, but I don't understand them
well enough to see how (or if) they might be useful to me here.
Hopefully I've included sufficient detail to explain my problem, if anyone can lead me out of the woods here, I'd really appreciate it.
From what I can tell, there is no way to switch prompt handlers halfway through running a Prompt
action, so you will need a single handler that can deal with both the case where there are still actions left to replay, and the case when you've resumed normal play.
The best way I see of solving this would be to add another StateT
transformer to your stack to store the remaining list of actions to perform. That way, the replay logic can be kept separate from the main game logic in basicIO
, and your replay handler can just call lift . basicIO
when there are no actions left, and do no-ops or pick actions out of the state otherwise.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With