Міни в Haskell і Gloss: швидке прототипування інтерактивної графіки

На Хабрахабре є вже чимало хороших статей хаскелю, але, на жаль, це здебільшого всілякі введення в ФП, роз'яснення якихось теоретичних штук (на кшталт монад, лямбда-числення або сімейств типів) і зовсім небагато практичних прикладів. Ні в якому разі не применшуючи їх корисності, спробую все-таки змістити дисбаланс, внести свою лепту в невдячна справа популяризації функциональщины і ще раз показати, що мова придатна не тільки для написання факториалов і неефективних швидких виконується в два рядки, але й для цілком практичних речей начебто швидкого прототипування.

Стаття постарається бути відносно real-world, але при цьому не втомлювати читача об'ємом або екзотичними предметними областями. «Графіка гри у навчанні — це завжди sexy», як заповідав великий В. С. Луговський, тому я набросаю просту гру, всенародно улюблений «Сапер». Розробка буде вестися «згори вниз» — це малопоширена, але заслуговує пильної уваги (як і сам хаскель) методологія, про яку я колись давно прочитав відмінною статті про шашках «Практиці функціонального програмування», і з тих пір вона запала мені в душу.

З голови на ноги

Ідея проста: ми починаємо не з нижчих елементарних типів і функцій, об'єднуючи їх в більш великі (як зазвичай робиться у повсякденному розробці на мейнстрімних мовами), а навпаки, описуємо самі високорівневі сутності, поступово розбиваючи їх на дрібні. Коли код пишеться одним-двома програмістами, а постановка задачі відома заздалегідь — або, навпаки, прототипируется незрозуміла штука, яка сто разів змінюється в ході розробки, — такий підхід, на мій погляд, краще класичного. По-перше, він дозволяє не забрідати в глухий кут кривої реалізації, якщо раптом ми почали з неправильних примітивів. По-друге, він слідує принципу KISS (а точніше, прогресивного методу JPEG — можна зупинитися на будь-якому рівні опрацювання, якщо результат влаштовує, і не вдаватися у зайві деталі, які при «знизу вгору» можуть здаватися першорядно важливими. По-третє, у разі гнучкого прототипування він спрощує уточнення завдання і вимог по ходу розробки.
(детальніше можна прочитати на Вікіпедії)

Крім того, відмінною рисою хаскеля є те, що завдяки ліниво і висновку типів оголошення і визначення типів і функцій можна ставити мало не в будь-якому порядку, і писати код прямолінійно, майже не повертаючись додому, щоб щось змінити чи дописати (хіба що імпорти модулів). Тому підхід «зверху вниз» там виявляє себе особливо чудово — можна легко починати з верхнеуровневых функцій, іноді ставлячи заглушки і дописуючи те, що вони викликають і використовують, вже потім. Розробку невеликої програми вести дуже просто — пишете в редакторі мінімум коду, періодично завантажуєте REPL і домагаєтеся його успішної компіляції, потім пробуєте викликати реалізовані функції, правите виповзають баги і имплементируете потрібні заглушки, і так поки робочий результат вас не задовольнить.

Я не буду пояснювати синтаксис хаскеля, базові функції і все таке інше, оскільки стаття і так вийшла докладної і пухловатой. Тому вона орієнтована на людину, що вже має деякий досвід у мові, а якщо ви таким не є, почніть з одного з вищезазначених введень. Замість цього я зосереджуся на демонстрації конкретних бібліотек і поясненнях, як думки, що виникають у процесі реальної розробки (яку я вів паралельно з написанням статті), лягають на код. З тієї ж причини стаття може скидатися на потік свідомості, і заздалегідь прошу вибачення у тих, кому такий стиль до вподоби.

Мінне поле

Отже, почнемо. Правила «Сапера» пояснювати, думаю, нікому не потрібно, тому відразу приступимо до реалізації, а обмеження спливуть по ходу справи. Гра повинна запускатися повноправним бинарником, тому перше, що у нас є, це точка входу — як і в багатьох інших мовах, це функція
main
. Вона повинна створити початкове ігрове поле і запустити на ньому гру:
main :: IO ()
main = do
let field = createField
startGame field

Що робить createField? Якимось чином створює поле, але, поки незрозуміло. Нехай для початку воно буде мати фіксований розмір, яке задамо в константах:
fieldSize@(fieldWidth, fieldHeight) = (15, 15) :: (Int, Int)
mineCount = 40 :: Int

createField :: Field
createField = undefined

startGame :: Field -> IO ()
startGame = undefined

Залишимо на місці реалізації
undefined
-заглушку, тому що поки незрозуміло, що з цими параметрами робити, і подумаємо, що ж таке Field. Поле «Сапере» — це двовимірний масив з клітин. У хаскеле масиви, звичайно ж, є, але з змінюваними масивами (а адже в процесі гри по полю будуть безперервно клацати) працювати не дуже зручно. Тому, щоб не возитися з мутабельностью в монадах IO і ST, використовуємо просту персистентную альтернативу — словник, де ключами будуть позиції клітин, а значеннями — їх стану:
Field type = Map Cell CellState --не забудемо дописати спочатку import Data.Map
type Cell = (Int, Int)

Які стани можуть бути у клітини?
  • Для початку, клітка може бути відкрита або закрита, і на неї може бути міна.
  • Якщо вона відкрита, на неї може бути або циферка з числом замінованих сусідів, або підірвана бомба, якщо сапер промахнувся.
  • Якщо закрита, сапер повинен мати можливість поставити або зняти прапорець.
Всі ці стани повинні бути взаємовиключними: не можна, наприклад, поставити прапорець на відкриту або відтворення цифру на закритій. Напрошується якийсь алгебраїчний тип, конструктори якого відповідають можливим станам:
data CellState = Closed {hasMine, hasFlag :: Bool} --Закрита; параметри - стоять прапор і міна
| Opened (Maybe Int) --Відкрита; параметр - скільки у неї небезпечних сусідів (і Nothing, якщо міна в ній самій)

Тип вийшов досить кострубатий, в ньому все звалено в купу. Такий варіант теж цілком здійсненний; але спробуємо ще подумати, і якщо вийде, піти іншим шляхом.
Зазначимо, що тут змішуються два стану клітини:
  • незмінне, спочатку генерується, внутрішнє (є чи немає міна);
  • змінюване візуальне (відкрита чи ні, чи є прапорець або цифра).
Спробуємо відокремити візуальне, а наявність хв зберігати окремо. У цьому випадку закрите стан стає непотрібним, оскільки порожню клітку можна просто не зберігати у словнику (до речі, у масиві довелося б зберігати всі). Що ж, зафіксуємо вищесказане в коді:
data CellState = Opened Int --Відкрита; параметр - цифра, яка буде відображатися
| Mine --Підірвалися; без параметрів
| Flag --Поставлено прапорець

Все просто! Мінімум варіантів, мінімум вкладених параметрів. І тоді
Field
на початку гри — це просто порожній
Map
:
createField = Data.Map.empty

Тобто тепер поле — це тільки те, що видно на екрані. Але тоді треба якось окремо зберігати внутрішній стан — набір хв «під цим полем. А адже так виходить ще простіше: міни визначаються просто набором клітин, на яких вони стоять, і в процесі гри це значення не змінюється:
type Mines = Set Cell --не забудемо import Data.Set

Перевіримо, чи все в порядку з компіляцією — оскільки в коді суцільні оголошення і майже ніяких визначень, проблем поки немає.

На відміну від клітин, стартовий набір хв ніяк не може бути порожнім. Оскільки всі хаскельные функції чисті і детерміновані, для створення випадкового набору нам доведеться працювати або в монаде IO, або тягати за собою псевдовипадковий генератор, або все життя грати на одному і тому ж полі. З одного боку, гру все одно запускати в IO і додаткове обмеження особливо не заважає; з іншого — більш узагальнене і чисте рішення теж не зашкодить. Як краще — питання відкрите, так що нехай буде другий варіант. Отже, нам знадобиться якийсь випадковий генератор (в хаскеле вони належать до класу типів
RandomGen
) і координата першої натиснутою клітини, щоб випадково не підриватися на першому ходу:
createMines :: RandomGen g => g -> Cell -> Mines

Як рівномірно вибрати n випадкових клітин поля — окреме питання. Щоб не морочитися, я не знайшов нічого кращого, як перемішати всі можливі клітини і взяти n перше. Ну і прибрати звідти стартову:
createMines g fst = Data.Set.fromList $ take mineCount $ shuffle g $
[(i, j) | i <- [0 .. fieldWidth - 1]
j <- [0 .. fieldHeight - 1]
, (i, j) /= fst]

Функції
shuffle
, перемішують список, в стандартній поставці немає компілятора. Що ж, нам допоможе всезнаючий джин на ім'я Хугль. Запитаємо, що він може розповісти по ключовому слову shuffle.



Ага, є цілий невеликий пакетик random-shuffle, вирішальний конкретно цю задачу. Ставимо його:
$> cabal install random-shuffle

і знаходимо функцію, яка в першому наближенні робить рівно те, що потрібно:
shuffle' :: RandomGen gen => [a] -> Int -> gen -> [a]

У неї навіть сигнатура схожа на нашу. Правда, вона приймає ще якийсь зайвий аргумент, який при ближчому розгляді виявляється довжиною виділеного списку. Навіщо він потрібен, я так і не зрозумів (можливо, щоб не вважати
length
ще раз, якщо вона відома заздалегідь), так що зробимо оберточку:
shuffle g l = shuffle' l (fieldWidth * fieldHeight - 1) g
-- import System.Random.Shuffle (shuffle'), щоб не конфліктувати іменами

Тепер це навіть можна скомпілювати, запустити і спробувати в REPL:
ghci> do {g <- getStdGen; return $ createMines g (0, 0)}
fromList[(0,1),(1,10),(2,5),(2,7),(2,12),(2,14),(3,8),(4,10),(6,7),(6,10),(7,11),(7,12),(8,4),(8,12),(9,7),(9,12),(10,14),(12,0),(12,5),(13,8)]

Відмінно. Тепер можна повернутися до main і зайнятися запуском ігрового процесу. Як показати віконце і промалювати на ньому що-небудь, сильно залежить від використовуваного графічного фреймворка. Я буду використовувати Gloss, призначений для створення додатків з простою двовимірної векторної графікою.
В бібліотеці не так давно злегка змінилися імена деяких стандартних функцій, тому, якщо код у вас не збирається, переконайтеся в наявності свіжої версії.


Наводимо глянець

Замість класичних імперативних функцій виду «отрисуй, потім отрисуй се», які можна бачити, наприклад, в хаскельных биндингах до OpenGL, SDL і іншим популярним графічним API, Gloss використовує фірмову ФП-шну ідею комбінаторів і композиції функцій. Те, що Gloss може промалювати на екрані — це Picture, якась абстрактна картинка. У нього є елементарні функції для створення картинок з простих графічних примітивів (зразок ліній, прямокутників і растрових картинок), функції для переміщення і масштабування картинок, об'єднання декількох картинок в одну, і т. д. і т. п. В результаті виходить декларативне побудова складних сцен з більш простих (знову «зверху вниз»): отрисовываемая у вікні сцена — це картинка, яка складається з картинок, які складаються з перетворених зображень… і так сэмь разів, поки в самому низу не будуть заховані під капотом примітиви OpenGL. Ще один важливий фактор — Gloss є маленька, але дуже горда підсистема для обробки подій користувальницького введення (що відрізняє її від концептуально схожою, але чисто статичною Diagrams), що нам дуже знадобиться. Прості приклади ви можете подивитися на офіційному сайті бібліотеки, а я в статті буду відразу використовувати все необхідне.
Для запуску інтерактивних сцен (які, крім малювання, здатні ще й змінюватися в часі і реагувати на зовнішні події) Gloss є функція play, що володіє надзвичайно громіздким 7-арным типом:
play :: Display -> Color -> Int -> world -> (world -> Picture) -> (Event -> world -> world) -> (Float -> world -> world) -> IO ()

Перші два аргументи тривіальні (параметри вікна або повноекранного режиму, і колір фону по замовчуванню). А далі починаються цікавинки.
Розберемо трохи принцип роботи цього самого
play
. Є сцена, або якийсь внутрішній «світ», стан якого зберігається в значенні типу
world
— це не тип, а змінна типу, тому
play
полиморфна і може працювати з будь-яким станом, яке ми введемо. Для роботи з станом застосовуються три callback-функції.
  • Коли движку потрібно його промалювати, він викликає п'ятий параметр — функцію типу
    world -> Picture
    , яка перетворює «стан світу» в картинку (назвемо її отрисовщиком, або
    renderer
    ).
  • Деяке число раз в секунду (задається третім параметром) світ може змінюватися, для чого є сьомий аргумент, що приймає поточний час, старий стан і повертає нове (назвемо її обновителем, або
    updater
    ).
  • І, нарешті, для обробки зовнішніх подій від користувача є шостий аргумент, аналогічним чином приймає подія, старий стан і повертає новий стан (її будемо кликати обробником, або
    handler
    ).
Зверніть увагу — всі функції чисті, крім самої
play
. До речі, схожий підхід з чистими функціями, обробляють різні події, використовувався в самому хаскеле в домонадную епоху (почитати можна тут).
Примітка: якщо, крім стандартних подій, потрібно реагувати з зовнішнім світом (наприклад, вантажити файли), тобто схожа функція, яка у всіх своїх обробниках працює з станом світу в монаде IO.
Спробуємо описати стан світу. У ньому повинні зберігатися, як мінімум, поле клітин і набір хв. Однак, набір хв невідомий, поки гравець не зробив перший хід, тому треба якось зробити відкладену ініціалізацію. Самий прямолінійний (і далеко не найкращий) варіант — це додати в стан відповідний прапор і генератор. Щоб уникнути використання одноразових полів, я придумав невеликий хак:
data GameState = GS
{ field :: Field
, mines :: Either StdGen Mines
}

Тоді, якщо
mines
у нас
Left
, це означає перший хід, і з нього ми згенеруємо набір, який стане
Right
-ом.
Тепер можна запустити гру:
import Graphics.Gloss.Interface.Pure.Game
<...>

startGame :: StdGen -> IO ()
startGame gen = play (InWindow windowSize (240, 160)) (greyN 0.25) 30 (initState gen) renderer handler updater

windowSize = both (* (round cellSize)) fieldSize
cellSize = 24

initState gen = GS createField (Left gen)

both f (a, b) = (f a, f b) --допоміжна функція, яка ще пригодиться 

Гра статична, тому
updater
нічого не робить.
handler
поки теж, але
undefined
не ставимо, щоб домогтися успішного відображення віконця.
updater _ = id
handler _ = id

renderer
для початку хай малює ціле поле з порожніми білими клітинками:
renderer _ = pictures [uncurry translate (cellToScreen (x, y)) $ color white $ rectangleSolid cellSize cellSize
| x <- [0 .. fieldWidth - 1], y <- [0 .. fieldHeight - 1]]

cellToScreen = both ((* cellSize) . fromIntegral)

Запустимо.



Упс — поле виїхало в кут. Виявляється, Gloss в якості системи координат за замовчуванням бере прийняту в математиці, де точка (0,0) — центр екрана, а вісь oy спрямована вгору. Тоді як у 2D-графіці зазвичай (0,0) — це лівий верхній кут, а oy йде вниз. З тієї ж причини і всі примітиви малюються щодо центру, а не кута. Розберемося з цим трохи пізніше, а спершу спробуємо зробити обробку яких-небудь подій.

Інтерактив

Подія Gloss — це алгебраїчний тип, конструктори якого — варіанти подій: натискання всіляких кнопок (
EventKey
), переміщення курсору (
EventMotion
), зміна розміру вікна (
EventResize
), кожне з яких несе в собі ще якісь специфічні параметри. Завдяки патерн-матчингу і частковим визначенням функції у вигляді клозов можна організовувати досить хитрі обробники різних приватних випадків, і виглядає це майже як методи виду
OnClick(MouseEventArgs e)
в якому-небудь WinForms.

Нас в першу чергу цікавить реакція на кнопки миші. Якщо поле порожнє, ми хотіли створити набір мін:
handler (EventKey (MouseButton LeftButton) Down _ mouse) gs@GS
{ mines = Left gen
} = gs { mines = Right $ createMines gen cell } where
cell = screenToCell mouse

screenToCell = both (round . (/ cellSize)) --тут обидві координати діляться на розмір клітини і округлюються, тим самим виходить її індекс

Запустимо. При найменшому русі гра падає з incomplete patterns. Дійсно — handler обробляє тільки один окремий випадок, тому потрібно залишити самий загальний клоз для обробки подій, які нас не цікавлять:
handler _ gs = gs

Тепер гра стабільно працює, але зовні рішуче нічого не змінилося, адже міни не видно. Додамо розтин клітини. Взагалі, в оригінальному «Сапере» автоматично розкривається ціла область з клітин з нулями, але для початку (пам'ятаємо — keep it simple!) зробимо обробку лише однієї клітини:
handler (EventKey (MouseButton LeftButton) Down _ mouse) gs@GS
{ field = field
, mines = Right mines
} = gs { field = Data.Map.insert cell opened field } where
cell@(cx, cy) = screenToCell mouse
opened = if cell `Data.Set.member` mines --перевіряємо, попалися на міну
then Mine
else Opened neighbours
--Обчислюємо число сусідів: це всі клітини, відстань до яких від натиснутої по обох осях не більше 1
neighbours = length [ () | i <- [-1 .. 1], j <- [-1 .. 1]
, (i, j) /= (0, 0)
, (cx + i, cy + j) `Data.Set.member` mines]

Тепер гру можна запустити і поклацати, але клітини як і раніше ніяк не відмальовує. Виправимо цю помилку. Для початку на місці розкритих клітин будемо малювати цифру (або
@
, якщо там бомба):
renderer GS { field = field } = pictures $ [uncurry translate (cellToScreen (x, y)) $ drawCell x y | x <- [0 .. fieldWidth - 1], y <- [0 .. fieldHeight - 1]] where
drawCell x y = case Data.Map.lookup (x, y) field of
Nothing -> color white $ rectangleSolid cellSize cellSize --клітина порожня
Just Mine -> pictures [ color red $ rectangleSolid cellSize cellSize
, scale 0.15 0.15 $ color black $ text "@"
]
Just (Opened n) -> pictures [ color green $ rectangleSolid cellSize cellSize
, scale 0.15 0.15 $ color black $ text $ show n
]

Нагадаю, що комбінатор
pictures
об'єднує список з декількох «картинок» в одну, при цьому побудова йде зліва направо, тобто елементи в хвості списку будуть малюватися поверх тих, що йдуть спочатку.

Тут ще варто відзначити, що стандартний векторний шрифт в Gloss відверто вырвиглазный і занадто великий (тому доводиться його зменшувати за допомогою
scale
), тому при першій можливості слід замінити його на растрові картинки. Але поки це відкладемо і спершу зробимо залишок ігровий логіки, щоб побачити, нарешті, ці змінені стани клітин поля. Спочатку встановлення прапорців — додамо клоз з обробкою правої кнопки мишки:
handler (EventKey (MouseButton RightButton) Down _ mouse) gs@GS
{ field = field
} = case Data.Map.lookup coord field of
Nothing -> gs { field = Data.Map.insert coord Flag field }
Just Flag -> gs { field = Data.Map.delete coord field }
_ -> gs
where coord = screenToCell mouse

і, відповідно, їх рендеринг:
drawCell x y = case M. lookup (x, y) field of
<...>
Just Flag -> pictures [ color yellow $ rectangleSolid cellSize cellSize
, scale 0.15 0.15 $ color black $ text "?"
]


Дублекод із створенням клітин варто було б порефакторить, але це я залишу на факультатив.



Клітини розкриваються, але мітки теж зміщені, передвинем їх допоміжною функцією, яка буде робити текстову мітку:
label = translate (-5) (-5) . scale 0.15 0.15 . color black . text

Крім того, зникла сітка — адже у заповнених геометричних фігур рамки немає. Потрібно додати її малювання окремо, причому поверх клітин, інакше вони будуть накладатися один на одного.
renderer GS { field = field } = pictures $ cells ++ grid where
grid = [uncurry translate (cellToScreen (x, y)) $ color black $ rectangleWire cellSize cellSize | x <- [0 .. fieldWidth - 1], y <- [0 .. fieldHeight - 1]]

Проекції координат

Тепер вже варто поправити координати. Звичайно, можна вручну переводити їх з однієї системи в іншу (і назад — адже координати мишки, одержувані в події, прив'язані не до вікна, а до абсолютній системі координат сцени, що з незвички може збентежити), але це втомлює. На щастя, Gloss включає механізм, який робить це за нас — проекції (viewports). Проекція — це перетворення, згідно з яким вікно відображається на площину сцени, і його можна не тільки переміщати, але й масштабувати і навіть повертати. Щоб поле вміщувалося на екран, наша проекція повинна бути зрушена на половину поля плюс половину клітини (оскільки клітка теж малюється з центру, а не з кута):
viewPort = ViewPort (both (negate . (/ 2) . (subtract cellSize)) $ cellToScreen fieldSize) 0 1 
--останні два параметри - це поворот і коефіцієнт масштабування

За допомогою
applyViewPortToPicture
можна застосувати проекцію до будь картинці, перетворивши її потрібним чином. А для зворотного перетворення (із системи координат картинки в систему координат проекції), яке потрібно для обробки позиції курсору, тобто
invertViewPort
. Підправимо відповідним чином наш код:
screenToCell = both (round . (/ cellSize)) . invertViewPort viewPort

renderer GS { field = field } = applyViewPortToPicture viewPort <...>




Вуаля! Тепер вікно малюється як треба, і в ньому навіть можна пограти. Крім того, в сукупності з проекціями Gloss має дуже прості засоби для реалізації прокрутки і масштабування екрану, що дозволяє зробити, наприклад, поле 1000х1000 клітин і переміщатися по ньому, як у картах.

Однак програти раніше, не можна — підрив на міні ні на що не впливає, і тому треба додати прапорець в стан гри, а в оброблювачі подій перевіряти:
data GameState = GS
{ field :: Field
, mines :: Either StdGen Mines
, gameOver :: Bool --На солодке - зробити enum-ADT з станами "у процесі", "програли", "виграли"
}
<...>
handler (EventKey (MouseButton LeftButton) Down _ mouse) gs@GS
{ field = field
, mines = Right mines
, gameOver = False
} = gs
{ field = Data.Map.insert cell opened field
, gameOver = exploded
} where
cell@(cx, cy) = screenToCell mouse
(opened, exploded) =
if cell `Data.Set.member` mines --перевіряємо, попалися на міну
then (Mine, True)
else (Opened neighbours, False)

Тепер гра після необережного кроку не дасть зробити ще один.

Трішки зручності

Залишилося найцікавіше — рекурсивна розкриття цілої області з порожніх клітин. Зробимо це своєрідним пошуком в глибину — симулируем тикання в сусідні клітини, якщо хв у них точно немає:
handler (EventKey (MouseButton LeftButton) Down _ mouse) gs@GS
{ field = field
, mines = Right mines
, gameOver = False
} = gs
{ field = newField
, gameOver = exploded
} where
newField = click cell field
exploded = case Data.Map.lookup cell newField of --Програш, якщо остання розкрита клітина - міна
Just Mine -> True
_ -> False
cell@(cx, cy) = screenToCell mouse
click :: Cell -> Field -> Field
click c@(cx, cy) f
| c `Data.Map.member` f = f --повторно клітку не обробляємо
| c `Data.Set.member` mines = put Mine --попалися на міну
| otherwise = let nf = put (Opened neighbours) in
if neighbours == 0
then Prelude.foldr click nf neighbourCells --Обійдемо сусідів
else nf
where
put state = Data.Map.insert c state f
--Обчислюємо число сусідів: це всі клітини, відстань до яких від натиснутої по кожній з осей не більше 1
neighbourCells = [ (cx + i, cy + j) | i <- [-1 .. 1], j <- [-1 .. 1] ]
neighbours = length $ Prelude.filter (`Data.Set.member` mines) neighbourCells

Запускаємо, тикаємо, і… гра висне. Як так, адже вона скомпилилась! На жаль, навіть хаскель не в силах застрахувати від всіх рантаймовых помилок. Конкретно таке зависання часто трапляється, коли програма входить в нескінченний цикл або рекурсію. І точно — ми ж ніяк не перевіряємо вихід за межі поля, тому обхід сусідів буде намагатися обходити всю нескінченну площину. Додамо обмеження:
neighbourCells = [ (i, j) | i <- [cx - 1 .. cx + 1], j <- [cy - 1 .. cy + 1]
, 0 <= i && i < fieldWidth
, 0 <= j && j < fieldHeight
] --Шкода, не можна написати 0 <= i < fieldWidth

Тепер, нарешті, можна насолодитися результатом:



Епілог

Звичайно, простір для поліпшень ще великий — додати чарівну комбінацію двох кнопок миші (для розкриття сусідів клітини, у якої вже є всі прапорці), прикрутити нормальну графіку (той же Gloss вміє вантажити битмапы), зробити налаштованим розмір поля і число хв (пробрасывая за допомогою монади Reader), і т. д. і т. п. Тим не менш, сподіваюся, я зміг показати, що боятися хаскеля зовсім не потрібно, і запрототипировать на ньому сотню рядків щось нескладне і інтерактивне — цілком підйомне завдання.

Повний код викладений на Pastebin.

Спасибі за увагу!

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.