anitomata-aseprite-0.1.0.0: library/Anitomata/Aseprite/Preprocessor.hs
{-# LANGUAGE ApplicativeDo #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE StrictData #-}
module Anitomata.Aseprite.Preprocessor
( preprocessor
, preprocessorWith
, PreprocessorOpts(..)
) where
import Prelude
import Control.Applicative ((<**>))
import Data.Aeson (FromJSON)
import Data.Kind (Type)
import Data.String.Interpolate (__i, i)
import GHC.Generics (Generic)
import GHC.Records (HasField(getField))
import ModuleMunging
( DeclBody(..), DeclName(..), ModuleDeclaration(..), ModuleFragment(..), ModuleImport(..)
, ModuleImportStyle(..), ModuleName(..), buildModule, displayModule
)
import Control.Monad qualified as Monad
import Data.Aeson qualified as Aeson
import Data.Aeson.Types qualified as Aeson.Types
import Data.Attoparsec.Text qualified as Attoparsec
import Data.List qualified as List
import Data.Maybe qualified as Maybe
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.IO qualified as Text.IO
import Options.Applicative qualified as Opt
import System.Directory qualified as Directory
import System.Exit qualified as Exit
import System.FilePath qualified as FilePath
preprocessor :: IO ()
preprocessor = do
opts <- Opt.execParser optsParser
preprocessorWith opts
preprocessorWith :: PreprocessorOpts -> IO ()
preprocessorWith opts = do
sourceFile' <- Directory.makeRelativeToCurrentDirectory sourceFile
atlasPath <- do
let dir = FilePath.dropFileName sourceFile'
let baseName = FilePath.takeBaseName sourceFile'
let atlasPath = dir ++ FilePath.addExtension baseName ".json"
atlasExists <- Directory.doesFileExist atlasPath
Monad.unless atlasExists do
Exit.die [i|aseprite2haskell: Aseprite atlas JSON does not exist at "#{atlasPath}"|]
pure atlasPath
atlas <- Aeson.eitherDecodeFileStrict atlasPath >>= \case
Left decodeErr -> Exit.die $ "aseprite2haskell: failed to parse JSON - " <> show decodeErr
Right x -> pure x
Text.IO.writeFile outputFile $ Text.pack $ displayModule $ buildModule (ModuleNameFromFilePath sourceFile') $ mconcat
[ mkBuildersAndSlices atlas
, mkFrames $ frames atlas
, mkDurations $ frames atlas
]
where
PreprocessorOpts
{ preprocessorOptsOrigSourceFile = sourceFile
, preprocessorOptsOutputFile = outputFile
} = opts
mkBuildersAndSlices :: Atlas -> ModuleFragment
mkBuildersAndSlices Atlas { frames, meta = Meta { frameTags = tags } }
| null frames = mempty
| otherwise = mconcat $ flattenFrameInfo $ List.groupBy groupFrameInfo $ zip frames [0 :: Int ..]
where
flattenFrameInfo :: [[(FrameInfo, Int)]] -> [ModuleFragment]
flattenFrameInfo = foldMap \case
[] -> []
(FrameInfo { filename }, frameIdx) : xs ->
let builderName = mkBuilderName filename
sliceName = [i|#{builderName}_slice|] :: String
(dir, mTagRepeat) = dirAndRepeatFromTags filename
in [ ModuleFragment
{ moduleFragmentImports =
[ ModuleImport
{ moduleImportName = "Prelude"
, moduleImportStyle = ModuleImportStyleOpen
}
, ModuleImport
{ moduleImportName = "Anitomata"
, moduleImportStyle = ModuleImportStyleExplicit
$ "AnimBuilder"
: "AnimDir(..)"
: "AnimSlice"
: "AnimSlice_(..)"
: builderFn dir
: [x | Maybe.isJust mTagRepeat, x <- ["AnimRepeat(..)", "repeatAnim"]]
}
, ModuleImport
{ moduleImportName = "Data.Vector.Unboxed"
, moduleImportStyle = ModuleImportStyleQualified $ Just "U"
}
]
, moduleFragmentDeclarations =
[ ModuleDeclaration True (DeclName builderName) $ DeclBody
case mTagRepeat of
Nothing ->
[__i|
#{builderName} :: AnimBuilder
#{builderName} = #{builderFn dir} #{sliceName}
|]
Just (TagRepeat n) ->
[__i|
#{builderName} :: AnimBuilder
#{builderName} = repeatAnim (AnimRepeatCount #{n}) $ #{builderFn dir} #{sliceName}
|]
, ModuleDeclaration True (DeclName sliceName) $ DeclBody
[__i|
#{sliceName} :: AnimSlice
#{sliceName} =
AnimSlice
{ animSliceDir = #{toAnimDir dir}
, animSliceFrameDurs = U.slice #{frameIdx} #{1 + length xs} #{durationsVecName}
, animSliceFrames = U.slice #{frameIdx} #{1 + length xs} #{framesVecName}
}
|]
]
}
]
groupFrameInfo :: (FrameInfo, Int) -> (FrameInfo, Int) -> Bool
groupFrameInfo (FrameInfo { filename = x }, _) (FrameInfo { filename = y }, _) =
getField @"file" x == getField @"file" y && getField @"tag" x == getField @"tag" y
dirAndRepeatFromTags :: FilenameField -> (Direction, Maybe TagRepeat)
dirAndRepeatFromTags FilenameField { file, tag } =
maybe (Forward, Nothing) ((,) <$> direction <*> getField @"repeat")
$ flip List.find tags \case
FrameTag { name = nameField } ->
file == getField @"file" nameField && tag == getField @"tag" nameField
toAnimDir :: Direction -> String
toAnimDir = \case
Forward -> "AnimDirForward"
Reverse -> "AnimDirBackward"
Pingpong -> "AnimDirForward"
PingpongReverse -> "AnimDirBackward"
builderFn :: Direction -> String
builderFn = \case
Forward -> "fromAnimSlice"
Reverse -> "fromAnimSlice"
Pingpong -> "pingpongAnimSlice"
PingpongReverse -> "pingpongAnimSlice"
mkFrames :: [FrameInfo] -> ModuleFragment
mkFrames = \case
[] -> mempty
xs ->
ModuleFragment
{ moduleFragmentImports =
[ ModuleImport
{ moduleImportName = "Prelude"
, moduleImportStyle = ModuleImportStyleOpen
}
, ModuleImport
{ moduleImportName = "Data.Vector.Unboxed"
, moduleImportStyle = ModuleImportStyleQualified $ Just "U"
}
, ModuleImport
{ moduleImportName = "Anitomata"
, moduleImportStyle = ModuleImportStyleExplicit ["AnimFrame", "AnimFrame_(..)"]
}
]
, moduleFragmentDeclarations =
[ ModuleDeclaration False (DeclName framesVecName) $ DeclBody $ List.intercalate "\n"
[ [i|#{framesVecName} :: U.Vector AnimFrame|]
, [i|#{framesVecName} = U.fromListN #{length xs}|]
, " [ " <> List.intercalate "\n , " (sourceRect . frame <$> xs)
, " ]"
]
]
}
where
sourceRect :: Frame -> String
sourceRect Frame { x, y, w, h } =
[i|AnimFrame { animFrameX = #{x}, animFrameY = #{y}, animFrameW = #{w}, animFrameH = #{h} }|]
mkDurations :: [FrameInfo] -> ModuleFragment
mkDurations = \case
[] -> mempty
xs ->
ModuleFragment
{ moduleFragmentImports =
[ ModuleImport
{ moduleImportName = "Prelude"
, moduleImportStyle = ModuleImportStyleOpen
}
, ModuleImport
{ moduleImportName = "Data.Vector.Unboxed"
, moduleImportStyle = ModuleImportStyleQualified $ Just "U"
}
]
, moduleFragmentDeclarations =
[ ModuleDeclaration False (DeclName durationsVecName) $ DeclBody $ List.intercalate "\n"
[ [i|#{durationsVecName} :: U.Vector Double|]
, [i|#{durationsVecName} = U.fromListN #{length xs}|]
, " [ " <> List.intercalate "\n , " (show . toSeconds . duration <$> xs)
, " ]"
]
]
}
where
toSeconds :: Int -> Double
toSeconds ms = fromIntegral ms / 1000
type Atlas :: Type
data Atlas = Atlas
{ frames :: [FrameInfo]
, meta :: Meta
} deriving stock (Generic)
deriving anyclass (FromJSON)
type Meta :: Type
newtype Meta = Meta
{ frameTags :: [FrameTag]
} deriving stock (Generic)
deriving anyclass (FromJSON)
type FrameTag :: Type
data FrameTag = FrameTag
{ name :: TagNameField
, from :: Int
, to :: Int
, direction :: Direction
, repeat :: Maybe TagRepeat
} deriving stock (Generic)
deriving anyclass (FromJSON)
type TagNameField :: Type
data TagNameField = TagNameField
{ file :: Text
, tag :: Text
}
instance FromJSON TagNameField where
parseJSON :: Aeson.Value -> Aeson.Types.Parser TagNameField
parseJSON = Aeson.withText "FromJSON TagNameField" \t -> do
case Attoparsec.parseOnly tagnameFieldParser t of
Left err -> fail err
Right x -> pure x
tagnameFieldParser :: Attoparsec.Parser TagNameField
tagnameFieldParser = do
file <- Attoparsec.takeWhile1 (/= '|')
_ <- Attoparsec.char '|'
tag <- Attoparsec.takeWhile1 (/= '|')
pure TagNameField { file, tag }
type TagRepeat :: Type
newtype TagRepeat = TagRepeat Int
instance FromJSON TagRepeat where
parseJSON :: Aeson.Value -> Aeson.Types.Parser TagRepeat
parseJSON = Aeson.withText "FromJSON TagRepeat" \t -> do
case Attoparsec.parseOnly tagRepeatParser t of
Left err -> fail err
Right x -> pure x
tagRepeatParser :: Attoparsec.Parser TagRepeat
tagRepeatParser = TagRepeat <$> Attoparsec.decimal
type Direction :: Type
data Direction
= Forward
| Reverse
| Pingpong
| PingpongReverse
instance FromJSON Direction where
parseJSON :: Aeson.Value -> Aeson.Types.Parser Direction
parseJSON = Aeson.withText "FromJSON Direction" \case
"forward" -> pure Forward
"reverse" -> pure Reverse
"pingpong" -> pure Pingpong
"pingpong_reverse" -> pure PingpongReverse
other -> fail $ "Invalid direction: " <> show other
type FrameInfo :: Type
data FrameInfo = FrameInfo
{ filename :: FilenameField
, frame :: Frame
, duration :: Int
} deriving stock (Generic, Show)
deriving anyclass (FromJSON)
framesVecName :: String
framesVecName = "frames"
durationsVecName :: String
durationsVecName = "durations"
type FilenameField :: Type
data FilenameField = FilenameField
{ file :: Text
, tag :: Text
-- | This is the frame index in the specific .aseprite file, NOT the frame
-- index in the overall texture atlas frames. It is only parsed here for
-- debugging's sake to cross-reference individual .aseprite files.
, frameIndex :: Int
} deriving stock (Show)
mkBuilderName :: FilenameField -> String
mkBuilderName FilenameField { file, tag } = [i|#{file}_#{tag}|]
instance FromJSON FilenameField where
parseJSON :: Aeson.Value -> Aeson.Types.Parser FilenameField
parseJSON = Aeson.withText "FromJSON FilenameField" \t -> do
case Attoparsec.parseOnly filenameFieldParser t of
Left err -> fail err
Right x -> pure x
filenameFieldParser :: Attoparsec.Parser FilenameField
filenameFieldParser = do
file <- Text.map sanitize <$> Attoparsec.takeWhile1 (/= '|')
_ <- Attoparsec.char '|'
tag <- Text.map sanitize <$> Attoparsec.takeWhile1 (/= '|')
_ <- Attoparsec.char '|'
frameIndex <- Attoparsec.decimal
pure FilenameField { file, tag, frameIndex }
where
sanitize :: Char -> Char
sanitize = \case
'-' -> '_'
c -> c
type Frame :: Type
data Frame = Frame
{ x :: Int
, y :: Int
, w :: Int
, h :: Int
} deriving stock (Generic, Show)
deriving anyclass (FromJSON)
optsParser :: Opt.ParserInfo PreprocessorOpts
optsParser = Opt.info (preprocessorOptsParser <**> Opt.helper) $ mconcat
[ Opt.fullDesc
, Opt.progDesc "Convert an Aseprite texture atlas JSON into Haskell code"
, Opt.header "aseprite2haskell - Aseprite -> Haskell preprocessor"
]
type PreprocessorOpts :: Type
data PreprocessorOpts = PreprocessorOpts
{ preprocessorOptsOrigSourceFile :: FilePath
, preprocessorOptsOutputFile :: FilePath
} deriving stock (Show)
preprocessorOptsParser :: Opt.Parser PreprocessorOpts
preprocessorOptsParser = do
preprocessorOptsOrigSourceFile <- Opt.argument Opt.str $ mconcat
[ Opt.metavar "SOURCE"
, Opt.help "Original source filepath (passed by GHC)"
]
-- It seems this positional argument only matters when multiple preprocessors
-- are stacked (e.g. CPP and this custom one). It is ignored here.
_ <- Opt.argument @String Opt.str $ mconcat
[ Opt.metavar "INPUT"
, Opt.help "Input filepath (passed by GHC)"
]
preprocessorOptsOutputFile <- Opt.argument Opt.str $ mconcat
[ Opt.metavar "OUTPUT"
, Opt.help "Output filepath (passed by GHC)"
]
pure PreprocessorOpts
{ preprocessorOptsOrigSourceFile
, preprocessorOptsOutputFile
}