packages feed

swarm-0.7.0.0: src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Frame.hs

-- |
-- SPDX-License-Identifier: BSD-3-Clause
--
-- Handling 'Swarm.TUI.Model.Frame' events.
module Swarm.TUI.Controller.EventHandlers.Frame (
  runFrameUI,
  runGameTickUI,

  -- ** Constants
  ticksPerFrameCap,
) where

import Brick
import Control.Lens as Lens
import Control.Monad (unless, void, when)
import Control.Monad.IO.Class (MonadIO (liftIO))
import Data.Bits
import Data.Map qualified as M
import Swarm.Game.Achievement.Attainment (achievement)
import Swarm.Game.Achievement.Definitions
import Swarm.Game.Achievement.Persistence
import Swarm.Game.State
import Swarm.Game.State.Substate
import Swarm.Game.Step (gameTick)
import Swarm.TUI.Controller.UpdateUI
import Swarm.TUI.Controller.Util
import Swarm.TUI.Model
import Swarm.TUI.Model.Achievements (popupAchievement)
import Swarm.TUI.Model.UI.Gameplay
import System.Clock

ticksPerFrameCap :: Int
ticksPerFrameCap = 30

-- | Run the game for a single /frame/ (/i.e./ screen redraw), then
--   update the UI.  Depending on how long it is taking to draw each
--   frame, and how many ticks per second we are trying to achieve,
--   this may involve stepping the game any number of ticks (including
--   zero).
runFrameUI :: Bool -> EventM Name AppState ()
runFrameUI forceRedraw = Brick.zoom playState runFrame >> updateAndRedrawUI forceRedraw

oneSecond :: Integer
oneSecond = 1_000_000_000 -- one second = 10^9 nanoseconds

runFramePlayState :: EventM Name PlayState ()
runFramePlayState = Brick.zoom scenarioState $ do
  -- Reset the needsRedraw flag.  While processing the frame and stepping the robots,
  -- the flag will get set to true if anything changes that requires redrawing the
  -- world (e.g. a robot moving or disappearing).
  gameState . needsRedraw .= False

  -- The logic here is taken from https://gafferongames.com/post/fix_your_timestep/ .

  curTime <- liftIO $ getTime Monotonic
  Brick.zoom (uiGameplay . uiTiming) $ do
    -- Find out how long the previous frame took, by subtracting the
    -- previous time from the current time.
    prevTime <- use lastFrameTime
    let frameTime = diffTimeSpec curTime prevTime

    -- Remember now as the new previous time.
    lastFrameTime .= curTime

    -- We now have some additional accumulated time to play with.  The
    -- idea is to now "catch up" by doing as many ticks as are supposed
    -- to fit in the accumulated time.  Some accumulated time may be
    -- left over, but it will roll over to the next frame.  This way we
    -- deal smoothly with things like a variable frame rate, the frame
    -- rate not being a nice multiple of the desired ticks per second,
    -- etc.
    accumulatedTime += frameTime

  -- Update TPS/FPS counters every second
  infoUpdateTime <- use (uiGameplay . uiTiming . lastInfoTime)
  let updateTime = toNanoSecs $ diffTimeSpec curTime infoUpdateTime
  when (updateTime >= oneSecond) $ do
    -- Wait for at least one second to have elapsed
    when (infoUpdateTime /= 0) $ do
      Brick.zoom (uiGameplay . uiTiming) $ do
        -- set how much frame got processed per second
        frames <- use frameCount
        uiFPS .= fromIntegral (frames * fromInteger oneSecond) / fromIntegral updateTime

        -- set how much ticks got processed per frame
        uiTicks <- use tickCount
        uiTPF .= fromIntegral uiTicks / fromIntegral frames

      -- ensure this frame gets drawn
      gameState . needsRedraw .= True

    Brick.zoom (uiGameplay . uiTiming) $ do
      -- Reset the counter and wait another seconds for the next update
      tickCount .= 0
      frameCount .= 0
      lastInfoTime .= curTime

  Brick.zoom (uiGameplay . uiTiming) $ do
    -- Increment the frame count
    frameCount += 1

    frameTickCount .= 0

-- | Run the game for a single frame, without updating the UI.
runFrame :: EventM Name PlayState ()
runFrame = do
  runFramePlayState

  -- Figure out how many ticks per second we're supposed to do,
  -- and compute the timestep `dt` for a single tick.
  lgTPS <- use (scenarioState . uiGameplay . uiTiming . lgTicksPerSecond)
  let dt
        | lgTPS >= 0 = oneSecond `div` (1 `shiftL` lgTPS)
        | otherwise = oneSecond * (1 `shiftL` abs lgTPS)

  -- Now do as many ticks as we need to catch up.
  runFrameTicks (fromNanoSecs dt)

-- | Do zero or more ticks, with each tick notionally taking the given
--   timestep, until we have used up all available accumulated time,
--   OR until we have hit the cap on ticks per frame, whichever comes
--   first.
runFrameTicks :: TimeSpec -> EventM Name PlayState ()
runFrameTicks dt = do
  timing <- use $ scenarioState . uiGameplay . uiTiming
  let a = timing ^. accumulatedTime
      t = timing ^. frameTickCount

  -- Ensure there is still enough time left, and we haven't hit the
  -- tick limit for this frame.
  when (a >= dt && t < ticksPerFrameCap) $ do
    -- If so, do a tick, count it, subtract dt from the accumulated time,
    -- and loop!
    runGameTick
    Brick.zoom (scenarioState . uiGameplay . uiTiming) $ do
      tickCount += 1
      frameTickCount += 1
      accumulatedTime -= dt
    runFrameTicks dt

-- | Run the game for a single tick, and update the UI.
runGameTickUI :: EventM Name AppState ()
runGameTickUI = Brick.zoom playState runGameTick >> void updateUI

updateAchievements :: EventM Name PlayState ()
updateAchievements = do
  -- Merge the in-game achievements with the master list in UIState
  achievementsFromGame <- use $ scenarioState . gameState . discovery . gameAchievements
  let wrappedGameAchievements = M.mapKeys GameplayAchievement achievementsFromGame

  oldMasterAchievementsList <- use $ progression . attainedAchievements
  progression . attainedAchievements %= M.unionWith (<>) wrappedGameAchievements

  -- Don't save to disk unless there was a change in the attainment list.
  let incrementalAchievements = wrappedGameAchievements `M.difference` oldMasterAchievementsList
  unless (null incrementalAchievements) $ Brick.zoom progression $ do
    mapM_ (popupAchievement . view achievement) incrementalAchievements

    newAchievements <- use attainedAchievements
    liftIO $ saveAchievementsInfo $ M.elems newAchievements

-- | Run the game for a single tick (/without/ updating the UI).
--   Every robot is given a certain amount of maximum computation to
--   perform a single world action (like moving, turning, grabbing,
--   etc.).
runGameTick :: EventM Name PlayState ()
runGameTick = do
  ticked <- zoomGameStateFromPlayState gameTick
  when ticked updateAchievements