Score rendering

This commit is contained in:
Oleg Sobolev 2026-03-30 00:01:17 +03:00
parent 1dd23d6e62
commit ffb2a087e1
8 changed files with 203 additions and 119 deletions

18
app/Assets.hs Normal file
View file

@ -0,0 +1,18 @@
module Assets where
import SDL
import qualified SDL.Image as IMG
import qualified SDL.Font as FONT
type Assets = (Texture, FONT.Font)
loadAssets :: Renderer -> IO Assets
loadAssets renderer = do
texture <- IMG.loadTexture renderer "assets/spritesheet.png"
font <- FONT.load "assets/font.ttf" 24
pure (texture, font)
freeAssets :: Assets -> IO ()
freeAssets (texture, font) = do
destroyTexture texture
FONT.free font

View file

@ -1,19 +1,32 @@
module Graphics ( module Graphics (
renderFrame renderFrame,
tileSize
) where ) where
import SDL import SDL
import SnakeLib import qualified SDL.Font as FONT
import Foreign.C (CInt) import Foreign.C (CInt)
import Data.Array (assocs) import Data.Array (assocs)
import Control.Monad (void) import Control.Monad (void)
renderFrame :: Renderer -> Texture -> Game -> IO () import Snake
renderFrame renderer texture game = do
import Assets
import qualified Data.Text as Text
spriteSize :: CInt
spriteSize = 16
tileSize :: CInt
tileSize = 64
renderFrame :: Renderer -> Assets -> Game -> IO ()
renderFrame renderer (texture, font) game = do
rendererDrawColor renderer $= V4 32 32 32 255 rendererDrawColor renderer $= V4 32 32 32 255
clear renderer clear renderer
rendererDrawColor renderer $= V4 255 255 255 255 rendererDrawColor renderer $= V4 255 255 255 255
renderGame renderer texture game renderGame renderer texture game
renderScore renderer font game
present renderer present renderer
renderGame :: Renderer -> Texture -> Game -> IO () renderGame :: Renderer -> Texture -> Game -> IO ()
@ -26,6 +39,19 @@ renderTile renderer texture (pos, tile) = case tile of
Apple -> renderSpriteAt renderer texture APPLE pos Apple -> renderSpriteAt renderer texture APPLE pos
SnakeSegment orientation -> renderSpriteAt renderer texture (snakeSprite orientation) pos SnakeSegment orientation -> renderSpriteAt renderer texture (snakeSprite orientation) pos
renderScore :: Renderer -> FONT.Font -> Game -> IO ()
renderScore renderer font game = do
let score = snakeSegmentCount game
surface <- FONT.blended font (V4 255 255 255 255) $ Text.pack ("Score: " ++ show score)
texture <- createTextureFromSurface renderer surface
freeSurface surface
width <- textureWidth <$> queryTexture texture
height <- textureHeight <$> queryTexture texture
copy renderer texture Nothing (Just (Rectangle (P (V2 20 20)) (V2 width height)))
destroyTexture texture
snakeSprite :: SnakeSegmentOrientation -> Sprite snakeSprite :: SnakeSegmentOrientation -> Sprite
snakeSprite HEAD_DOWN = SNAKE_HEAD_DOWN snakeSprite HEAD_DOWN = SNAKE_HEAD_DOWN
snakeSprite HEAD_LEFT = SNAKE_HEAD_LEFT snakeSprite HEAD_LEFT = SNAKE_HEAD_LEFT
@ -45,11 +71,11 @@ snakeSprite TAIL_LEFT = SNAKE_TAIL_LEFT
renderSpriteAt :: Renderer -> Texture -> Sprite -> (Int, Int) -> IO () renderSpriteAt :: Renderer -> Texture -> Sprite -> (Int, Int) -> IO ()
renderSpriteAt renderer texture sprite (x, y) = do renderSpriteAt renderer texture sprite (x, y) = do
let srcRect = getSpriteSheetLocation $ getSpriteIndex sprite let srcRect = getSpriteSheetLocation $ getSpriteIndex sprite
let dstRect = Rectangle (P (V2 (64 * (fromIntegral x)) (64 * (fromIntegral y)))) (V2 64 64) let dstRect = Rectangle (P (V2 (tileSize * (fromIntegral x)) (tileSize * (fromIntegral y)))) (V2 tileSize tileSize)
copy renderer texture (Just srcRect) (Just dstRect) copy renderer texture (Just srcRect) (Just dstRect)
getSpriteSheetLocation :: (Int, Int) -> Rectangle CInt getSpriteSheetLocation :: (Int, Int) -> Rectangle CInt
getSpriteSheetLocation (y, x) = Rectangle (P (V2 (16 * (fromIntegral x)) (16 * (fromIntegral y)))) (V2 16 16) getSpriteSheetLocation (y, x) = Rectangle (P (V2 (spriteSize * (fromIntegral x)) (spriteSize * (fromIntegral y)))) (V2 spriteSize spriteSize)
getSpriteIndex :: Sprite -> (Int, Int) getSpriteIndex :: Sprite -> (Int, Int)
getSpriteIndex SNAKE_HEAD_DOWN = (0, 0) getSpriteIndex SNAKE_HEAD_DOWN = (0, 0)

View file

@ -1,25 +1,28 @@
module Main where module Main where
import SDL import SDL
import qualified SDL.Image as IMG import qualified SDL.Font as FONT
import qualified Data.Text as Text import qualified Data.Text as Text
import Control.Monad (unless) import Control.Monad (unless)
import Graphics
import SnakeLib
import SDL.Raw (getTicks) import SDL.Raw (getTicks)
import Data.Word (Word32) import Data.Word (Word32)
import Foreign.C (CInt)
import Snake
import Assets
import Graphics
main :: IO () main :: IO ()
main = do main = do
initializeAll initializeAll
window <- createWindow (Text.pack "Hello, World") defaultWindow FONT.initialize
window <- createWindow (Text.pack "Haskell Snake") defaultWindow {windowInitialSize = snakeWindowSize}
renderer <- createRenderer window (-1) defaultRenderer renderer <- createRenderer window (-1) defaultRenderer
texture <- IMG.loadTexture renderer "assets/spritesheet.png" assets <- loadAssets renderer
appLoop renderer assets (initialState boardSize (2, 2))
appLoop renderer texture (initialState (10, 10) (2, 2)) freeAssets assets
destroyTexture texture
destroyRenderer renderer destroyRenderer renderer
destroyWindow window destroyWindow window
@ -29,32 +32,47 @@ targetFps = 5
targetFrameMs :: Word32 targetFrameMs :: Word32
targetFrameMs = 1000 `div` targetFps targetFrameMs = 1000 `div` targetFps
appLoop :: Renderer -> Texture -> Game -> IO () boardSize :: (Int, Int)
appLoop renderer texture game = do boardSize = (12, 8)
boardWindowSize :: (Int, Int) -> V2 CInt
boardWindowSize (w, h) = V2 (fromIntegral w * tileSize) (fromIntegral h * tileSize)
snakeWindowSize :: V2 CInt
snakeWindowSize = boardWindowSize boardSize
appLoop :: Renderer -> Assets -> Game -> IO ()
appLoop renderer assets game = do
frameStart <- getTicks frameStart <- getTicks
events <- pollEvents events <- pollEvents
let eventIsExitPress event = case eventPayload event of let eventIsExitPress event = case eventPayload event of
KeyboardEvent keyboardEvent -> KeyboardEvent keyboardEvent ->
keyboardEventKeyMotion keyboardEvent == Pressed && keyboardEventKeyMotion keyboardEvent == Pressed &&
keysymKeycode (keyboardEventKeysym keyboardEvent) == KeycodeQ keysymKeycode (keyboardEventKeysym keyboardEvent) == KeycodeQ
WindowClosedEvent _ -> True WindowClosedEvent _ -> True
_ -> False _ -> False
exitPressed = any eventIsExitPress events exitPressed = any eventIsExitPress events
let eventDirectionPressed event = case eventPayload event of let eventIsRestartPress event = case eventPayload event of
KeyboardEvent keyboardEvent ->
keyboardEventKeyMotion keyboardEvent == Pressed &&
keysymKeycode (keyboardEventKeysym keyboardEvent) == KeycodeR
_ -> False
restartPresssed = any eventIsRestartPress events
let eventDirectionPress event = case eventPayload event of
KeyboardEvent keyboardEvent -> KeyboardEvent keyboardEvent ->
if keyboardEventKeyMotion keyboardEvent == Pressed if keyboardEventKeyMotion keyboardEvent == Pressed
then getInputByKeycode $ keysymKeycode (keyboardEventKeysym keyboardEvent) then getInputByKeycode $ keysymKeycode (keyboardEventKeysym keyboardEvent)
else NothingPressed else NothingPressed
_ -> NothingPressed _ -> NothingPressed
input = foldMap eventDirectionPressed events input = foldMap eventDirectionPress events
frameEnd <- getTicks (_, updatedGame) <- if restartPresssed then pure (IntoEmpty, initialState (10, 8) (2, 2)) else runTick game input
let elapsed = frameEnd - frameStart renderFrame renderer assets updatedGame
(_, updatedGame) <- runTick game input
renderFrame renderer texture updatedGame
unless exitPressed $ do unless exitPressed $ do
frameEnd <- getTicks
let elapsed = frameEnd - frameStart
delay (targetFrameMs - elapsed) delay (targetFrameMs - elapsed)
appLoop renderer texture updatedGame appLoop renderer assets updatedGame
getInputByKeycode :: Keycode -> MovementInput getInputByKeycode :: Keycode -> MovementInput
getInputByKeycode KeycodeW = UpPressed getInputByKeycode KeycodeW = UpPressed

BIN
assets/font.ttf Normal file

Binary file not shown.

View file

@ -58,7 +58,9 @@ library
import: warnings import: warnings
-- Modules exported by the library. -- Modules exported by the library.
exposed-modules: SnakeLib exposed-modules:
Snake
Tile
-- Modules included in this library but not exported. -- Modules included in this library but not exported.
-- other-modules: -- other-modules:
@ -88,6 +90,7 @@ executable snake-hs
-- Modules included in this executable, other than Main. -- Modules included in this executable, other than Main.
other-modules: other-modules:
Assets
Graphics Graphics
-- LANGUAGE extensions used by modules in this package. -- LANGUAGE extensions used by modules in this package.
@ -100,7 +103,8 @@ executable snake-hs
text, text,
array, array,
sdl2, sdl2,
sdl2-image sdl2-image,
sdl2-ttf
-- Directories containing source files. -- Directories containing source files.
hs-source-dirs: app hs-source-dirs: app

View file

@ -1,6 +1,5 @@
{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleContexts #-}
module SnakeLib ( module Snake (
MovementInput (..),
Tile (..), Tile (..),
Pos, Pos,
Board, Board,
@ -10,18 +9,22 @@ module SnakeLib (
runTick, runTick,
MovementInput (..),
MovementResult (..),
SnakeSegmentOrientation (..), SnakeSegmentOrientation (..),
Direction (..), Direction (..),
growHead, growHead,
shrinkTail shrinkTail,
snakeSegmentCount
) where ) where
import Data.Array import Data.Array
import Control.Monad.State import Control.Monad.State
import Control.Monad (when, unless) import Control.Monad (when)
import System.Random (randomRIO) import System.Random (randomRIO)
import Tile
data Tile = SnakeSegment SnakeSegmentOrientation | Apple | Empty deriving (Eq, Show)
type Pos = (Int, Int) type Pos = (Int, Int)
@ -29,6 +32,18 @@ type Board = Array Pos Tile
data Game = Game {gameBoard :: Board, gameBoardSize :: (Int, Int), snakeHead :: Pos, snakeTail :: Pos} data Game = Game {gameBoard :: Board, gameBoardSize :: (Int, Int), snakeHead :: Pos, snakeTail :: Pos}
data MovementInput = UpPressed | RightPressed | DownPressed | LeftPressed | NothingPressed deriving (Eq, Show)
instance Semigroup MovementInput where
(<>) l NothingPressed = l
NothingPressed <> r = r
l <> _ = l
instance Monoid MovementInput where
mempty = NothingPressed
data MovementResult = IntoApple | IntoSnake | IntoEmpty deriving (Eq, Show)
emptyBoard :: (Int, Int) -> Array (Int, Int) Tile emptyBoard :: (Int, Int) -> Array (Int, Int) Tile
emptyBoard (w, h) = array ((0, 0), (w-1, h-1)) [((i, j), Empty) | i <- [0..w-1], j <- [0..h-1]] emptyBoard (w, h) = array ((0, 0), (w-1, h-1)) [((i, j), Empty) | i <- [0..w-1], j <- [0..h-1]]
@ -51,7 +66,7 @@ gameTick input = do
advanceSnake :: MonadState Game m => MovementInput -> m MovementResult advanceSnake :: MonadState Game m => MovementInput -> m MovementResult
advanceSnake input = do advanceSnake input = do
idleDirection <- headDirection idleDirection <- getHeadDirection
let inputDirection = case input of let inputDirection = case input of
UpPressed -> UP UpPressed -> UP
RightPressed -> RIGHT RightPressed -> RIGHT
@ -67,8 +82,8 @@ advanceSnake input = do
Empty -> moveHead direction >> pure IntoEmpty Empty -> moveHead direction >> pure IntoEmpty
SnakeSegment _ -> pure IntoSnake SnakeSegment _ -> pure IntoSnake
headDirection :: MonadState Game m => m Direction getHeadDirection :: MonadState Game m => m Direction
headDirection = gets f getHeadDirection = gets f
where where
f :: Game -> Direction f :: Game -> Direction
f game = case tileAt (gameBoard game) (snakeHead game) of f game = case tileAt (gameBoard game) (snakeHead game) of
@ -90,27 +105,6 @@ moveHead direction = do
let modifiedBoard = board // [(pos, SnakeSegment $ growHead orientation direction), (destination, SnakeSegment $ getNewHead direction)] let modifiedBoard = board // [(pos, SnakeSegment $ growHead orientation direction), (destination, SnakeSegment $ getNewHead direction)]
modify (\s -> s {gameBoard=modifiedBoard, snakeHead=destination}) modify (\s -> s {gameBoard=modifiedBoard, snakeHead=destination})
growHead :: SnakeSegmentOrientation -> Direction -> SnakeSegmentOrientation
growHead HEAD_UP UP = VERTICAL
growHead HEAD_UP LEFT = TURN_DOWN_LEFT
growHead HEAD_UP RIGHT = TURN_DOWN_RIGHT
growHead HEAD_RIGHT RIGHT = HORIZONTAL
growHead HEAD_RIGHT UP = TURN_UP_LEFT
growHead HEAD_RIGHT DOWN = TURN_DOWN_LEFT
growHead HEAD_DOWN DOWN = VERTICAL
growHead HEAD_DOWN RIGHT = TURN_UP_RIGHT
growHead HEAD_DOWN LEFT = TURN_UP_LEFT
growHead HEAD_LEFT LEFT = HORIZONTAL
growHead HEAD_LEFT DOWN = TURN_DOWN_RIGHT
growHead HEAD_LEFT UP = TURN_UP_RIGHT
growHead _ _ = error "Invalid growHead arguments"
getNewHead :: Direction -> SnakeSegmentOrientation
getNewHead UP = HEAD_UP
getNewHead RIGHT = HEAD_RIGHT
getNewHead DOWN = HEAD_DOWN
getNewHead LEFT = HEAD_LEFT
shrinkSnake :: MonadState Game m => m () shrinkSnake :: MonadState Game m => m ()
shrinkSnake = do shrinkSnake = do
pos <- gets snakeTail pos <- gets snakeTail
@ -130,25 +124,6 @@ shrinkSnake = do
let modifiedBoard = board // [(pos, Empty), (destination, SnakeSegment $ shrinkTail orientation direction)] let modifiedBoard = board // [(pos, Empty), (destination, SnakeSegment $ shrinkTail orientation direction)]
modify (\s -> s {gameBoard=modifiedBoard, snakeTail=destination}) modify (\s -> s {gameBoard=modifiedBoard, snakeTail=destination})
shrinkTail :: SnakeSegmentOrientation -> Direction -> SnakeSegmentOrientation
shrinkTail HEAD_UP UP = TAIL_UP
shrinkTail HEAD_RIGHT RIGHT = TAIL_RIGHT
shrinkTail HEAD_DOWN DOWN = TAIL_DOWN
shrinkTail HEAD_LEFT LEFT = TAIL_LEFT
shrinkTail TURN_UP_RIGHT LEFT = TAIL_UP
shrinkTail TURN_UP_RIGHT DOWN = TAIL_RIGHT
shrinkTail TURN_DOWN_RIGHT LEFT = TAIL_DOWN
shrinkTail TURN_DOWN_RIGHT UP = TAIL_RIGHT
shrinkTail TURN_DOWN_LEFT RIGHT = TAIL_DOWN
shrinkTail TURN_DOWN_LEFT UP = TAIL_LEFT
shrinkTail TURN_UP_LEFT RIGHT = TAIL_UP
shrinkTail TURN_UP_LEFT DOWN = TAIL_LEFT
shrinkTail HORIZONTAL RIGHT = TAIL_RIGHT
shrinkTail HORIZONTAL LEFT = TAIL_LEFT
shrinkTail VERTICAL UP = TAIL_UP
shrinkTail VERTICAL DOWN = TAIL_DOWN
shrinkTail o d = error ("Invalid shrinkTail arguments: " ++ show o ++ " " ++ show d)
spawnApple :: (MonadState Game m, MonadIO m) => m () spawnApple :: (MonadState Game m, MonadIO m) => m ()
spawnApple = do spawnApple = do
(w, h) <- gets gameBoardSize (w, h) <- gets gameBoardSize
@ -158,6 +133,12 @@ spawnApple = do
Empty -> modify (\s -> s {gameBoard = gameBoard s // [(pos, Apple)]}) Empty -> modify (\s -> s {gameBoard = gameBoard s // [(pos, Apple)]})
_ -> spawnApple _ -> spawnApple
snakeSegmentCount :: Game -> Int
snakeSegmentCount = length . filter isSnakeSegment . elems . gameBoard
isSnakeSegment :: Tile -> Bool
isSnakeSegment (SnakeSegment _) = True
isSnakeSegment _ = False
tileAt :: Board -> Pos -> Tile tileAt :: Board -> Pos -> Tile
tileAt board (x, y) = (board ! (x, y)) tileAt board (x, y) = (board ! (x, y))
@ -174,36 +155,5 @@ shiftPos (x, y) direction = do
size <- gets gameBoardSize size <- gets gameBoardSize
pure $ wrapPos size (x + dx, y + dy) pure $ wrapPos size (x + dx, y + dy)
data MovementInput = UpPressed | RightPressed | DownPressed | LeftPressed | NothingPressed deriving (Eq, Show)
instance Semigroup MovementInput where
(<>) l NothingPressed = l
NothingPressed <> r = r
l <> _ = l
instance Monoid MovementInput where
mempty = NothingPressed
data MovementResult = IntoApple | IntoSnake | IntoEmpty deriving (Eq, Show)
data SnakeSegmentOrientation =
HEAD_DOWN | HEAD_LEFT | HEAD_UP | HEAD_RIGHT |
TURN_UP_RIGHT | TURN_DOWN_RIGHT | TURN_DOWN_LEFT | TURN_UP_LEFT |
VERTICAL | HORIZONTAL |
TAIL_UP | TAIL_RIGHT | TAIL_DOWN | TAIL_LEFT
deriving (Eq, Show)
data Direction = DOWN | LEFT | UP | RIGHT deriving (Eq, Show)
areOpposite :: Direction -> Direction -> Bool
areOpposite UP DOWN = True
areOpposite DOWN UP = True
areOpposite LEFT RIGHT = True
areOpposite RIGHT LEFT = True
areOpposite _ _ = False
getDelta :: Direction -> (Int, Int)
getDelta UP = (0, -1)
getDelta RIGHT = (1, 0)
getDelta DOWN = (0, 1)
getDelta LEFT = (-1, 0)

74
src/Tile.hs Normal file
View file

@ -0,0 +1,74 @@
module Tile (
Tile (..),
SnakeSegmentOrientation (..),
Direction (..),
areOpposite,
growHead,
getNewHead,
shrinkTail,
getDelta
) where
data Tile = SnakeSegment SnakeSegmentOrientation | Apple | Empty deriving (Eq, Show)
data SnakeSegmentOrientation =
HEAD_DOWN | HEAD_LEFT | HEAD_UP | HEAD_RIGHT |
TURN_UP_RIGHT | TURN_DOWN_RIGHT | TURN_DOWN_LEFT | TURN_UP_LEFT |
VERTICAL | HORIZONTAL |
TAIL_UP | TAIL_RIGHT | TAIL_DOWN | TAIL_LEFT
deriving (Eq, Show)
data Direction = DOWN | LEFT | UP | RIGHT deriving (Eq, Show)
areOpposite :: Direction -> Direction -> Bool
areOpposite UP DOWN = True
areOpposite DOWN UP = True
areOpposite LEFT RIGHT = True
areOpposite RIGHT LEFT = True
areOpposite _ _ = False
growHead :: SnakeSegmentOrientation -> Direction -> SnakeSegmentOrientation
growHead HEAD_UP UP = VERTICAL
growHead HEAD_UP LEFT = TURN_DOWN_LEFT
growHead HEAD_UP RIGHT = TURN_DOWN_RIGHT
growHead HEAD_RIGHT RIGHT = HORIZONTAL
growHead HEAD_RIGHT UP = TURN_UP_LEFT
growHead HEAD_RIGHT DOWN = TURN_DOWN_LEFT
growHead HEAD_DOWN DOWN = VERTICAL
growHead HEAD_DOWN RIGHT = TURN_UP_RIGHT
growHead HEAD_DOWN LEFT = TURN_UP_LEFT
growHead HEAD_LEFT LEFT = HORIZONTAL
growHead HEAD_LEFT DOWN = TURN_DOWN_RIGHT
growHead HEAD_LEFT UP = TURN_UP_RIGHT
growHead _ _ = error "Invalid growHead arguments"
getNewHead :: Direction -> SnakeSegmentOrientation
getNewHead UP = HEAD_UP
getNewHead RIGHT = HEAD_RIGHT
getNewHead DOWN = HEAD_DOWN
getNewHead LEFT = HEAD_LEFT
shrinkTail :: SnakeSegmentOrientation -> Direction -> SnakeSegmentOrientation
shrinkTail HEAD_UP UP = TAIL_UP
shrinkTail HEAD_RIGHT RIGHT = TAIL_RIGHT
shrinkTail HEAD_DOWN DOWN = TAIL_DOWN
shrinkTail HEAD_LEFT LEFT = TAIL_LEFT
shrinkTail TURN_UP_RIGHT LEFT = TAIL_UP
shrinkTail TURN_UP_RIGHT DOWN = TAIL_RIGHT
shrinkTail TURN_DOWN_RIGHT LEFT = TAIL_DOWN
shrinkTail TURN_DOWN_RIGHT UP = TAIL_RIGHT
shrinkTail TURN_DOWN_LEFT RIGHT = TAIL_DOWN
shrinkTail TURN_DOWN_LEFT UP = TAIL_LEFT
shrinkTail TURN_UP_LEFT RIGHT = TAIL_UP
shrinkTail TURN_UP_LEFT DOWN = TAIL_LEFT
shrinkTail HORIZONTAL RIGHT = TAIL_RIGHT
shrinkTail HORIZONTAL LEFT = TAIL_LEFT
shrinkTail VERTICAL UP = TAIL_UP
shrinkTail VERTICAL DOWN = TAIL_DOWN
shrinkTail o d = error ("Invalid shrinkTail arguments: " ++ show o ++ " " ++ show d)
getDelta :: Direction -> (Int, Int)
getDelta UP = (0, -1)
getDelta RIGHT = (1, 0)
getDelta DOWN = (0, 1)
getDelta LEFT = (-1, 0)

View file

@ -1,7 +1,7 @@
module Main (main) where module Main (main) where
import Data.Array (elems, (!)) import Data.Array ((!))
import SnakeLib import Snake
import Test.Tasty (TestTree, defaultMain, testGroup) import Test.Tasty (TestTree, defaultMain, testGroup)
import Test.Tasty.HUnit (assertEqual, testCase) import Test.Tasty.HUnit (assertEqual, testCase)
@ -125,9 +125,3 @@ runTickTests =
assertEqual "tail advanced" (1, 0) (snakeTail step4) assertEqual "tail advanced" (1, 0) (snakeTail step4)
] ]
snakeSegmentCount :: Game -> Int
snakeSegmentCount = length . filter isSnakeSegment . elems . gameBoard
isSnakeSegment :: Tile -> Bool
isSnakeSegment (SnakeSegment _) = True
isSnakeSegment _ = False