rea: Библиотека для декларативного описания стратегических игр
Содержание
После прочтения довольно любопытной исследовательской работы “Resource Entity Action: A Generalized Design Pattern for RTS games” я решил реализовать предложенную концепцию (в несколько упрощённом виде) на Haskell. Ниже пойдёт речь об
идее подхода Resource-Entity-Action к проектированию RTS-игр (Command & Conquer, Age of Empires, StarCraft, …) и прочих симуляций, а также о том, как использовать библиотеку rea
.
Подход Resource-Entity-Action⌗
Предложенная в статье выше идея довольно проста по своей сути: для описания логики некоторой RTS-игры мы используем трансферы абстрактных ресурсов между сущностями.
Resource⌗
Под ресурсом может пониматься любая количественная или качественная информация (характеристика), выделяемая в нашей стратегической игре.
Например, т.н. “юниты” на компьютерном поле боя обычно имеют позицию (координаты), запас снарядов, уровень повреждений, текущие приказы, текущий интервал перезарядки орудия, флаг “убит”/“живой” и т.д. В зависимости от задачей и механики игры, под ресурсом можно вообразить и более абстрактный компонент “юнита”: например, “система обнаружения, идентификации и отслеживания местоположения”.
Все параметры механики игры так или иначе обобщаются и представляются этими ресурсами.
Entity⌗
Сущность – это контейнер для одного и более типов ресурсов (не существует ресурса как такового в отрыве от сущности, в которой он находится). Например, самыми очевидными сущностями выступают юниты (армии, базы, космические корабли и т.п.).
В текущем дизайне rea
я заложил одно важное ограничение: система допускает трансферы только между сущностями одного
типа (то есть описание игры строится на описании одного типа Unit
и его компонентов-ресурсов).
Action⌗
Под действием понимается одна инструкция по взаимному переносу ресурсов (обмену ресурсами) между двумя сущностями. Далее я буду использовать вместо “действия” слово “трансфер”. Итак, в исходной научной работе под трансфером довольно широко понимается конструкция, в которой указано:
- В какую сущность
- При выполнении каких условий
- Из какой сущности
- Переносить какие ресурсы и сколько (заметим, что перенос подразумевает взаимную мутацию сущностей: в одной прибыло, а другой убыло, - значит на выходе изменены обе сущности)
- Дополнительно: если дан тест на “пороговое значение” (threshold) одного из ресурсов целевой сущности (например, урон достиг критического значения) и этот тест срабатывает
- Что делать с сущностью при достижении данного порогового значения
Это я обобщил. На самом деле в работе описывается три трансфера:
- constant transfer (перенос в единственную сущность из константы, т.е. просто изменение значений ресурсов внутри одной сущности при выполнении данных условий)
- mutable transfer (полноценный обмен ресурсами между сущностями, состоящими в некотором отношении друг с другом)
- threshold transfer (два предыдущих трансфера с добавленным “пороговым тестом” и трансфером)
Решения при дизайне rea⌗
На мой взгляд, авторы понимают трансферы довольно широко и либерально, что может создать дополнительные трудности в проектировании и понимании мехиники игры: если один трансфер способен за раз изменить много ресурсов внутри сущности, то что мешает нам реализовать всю логику игры полностью в одном большом трансфере? Насколько сложным должен быть тест (предикат/отношение), который должен сработать или не сработать, если трансфер задействует множество ресурсов одновременно?
Я подумал, что куда полезнее будет ограничить количество ресурсов, передаваемых одним трансфером, и поэтому при дизайне rea
решил наложить следующие ограничения:
- В
rea
существует четыре типа трансферов: constant, mutable, threshold constant и threshold mutable. - Все они не работают с наборами ресурсов, а используют только обособленные типы ресурсов (при создании трансферов для этой цели применяются линзы).
- В интранзитивном случае (когда меняется одна сущность, т.е. constant и threshold constant) в качестве условия активации трансфера служит обыкновенный предикат над сущностью (а также некоторым environment)
- В транзитивном случае, когда меняются обе сущности, условием активации служит простое отношение между сущностями (т.е. функция из квадрата сущностей в
True
/False
). - В качестве “порогового теста” в threshold-трансферах применяется предикат строго над тем же ресурсом, что и был задействован в данном трансфере для целевой сущности.
- При срабатывании “порогового теста”, система фактически исполняет обыкновенный constant-трансфер с предикатом
True
.
Библиотека rea⌗
Итак, пользоваться библиотекой намного проще, чем читать о её дизайне.
Интерфейс⌗
Для построения четырёх трансферов применяются четыре соответствующие функции, которым и передаются предикаты/отношения и линзы для доступа (из которых компилятор выводит типы и гарантирует корректность). Вот их сигнатуры:
-- | Constant resource transfer
cx :: (env -> e -> Bool)
-> ASetter' e r
-> (env -> e -> r)
-> Transfer env e
cx pred sel rf =
...
-- | Mutable resource transfer (exchange)
mx :: (env -> e -> e -> Bool)
-> ASetter' e rsrc
-> ASetter' e rdst
-> (env -> e -> e -> (rsrc, rdst))
-> Transfer env e
mx rel srcsel dstsel rf =
...
-- | Constant resource transfer with a threshold transfer
tcx :: (env -> e -> Bool)
-> Lens' e r1
-> (env -> e -> r1)
-> (r1 -> Bool)
-> ASetter' e r2
-> (env -> e -> r2)
-> Transfer env e
tcx pred sel rf tpred tsel trf =
...
-- | Mutable resource transfer with a threshold on first
tmx :: (env -> e -> e -> Bool)
-> Lens' e rsrc
-> ASetter' e rdst
-> (env -> e -> e -> (rsrc, rdst))
-> (rsrc -> Bool)
-> ASetter' e r
-> (env -> e -> r)
-> Transfer env e
tmx rel srcsel dstsel rf tpred tsel trf =
...
Как видно из сигнатур, всё довольно просто (мнемоники в именах параметров: rf
- resource function, sel
- selector).
Для выполнения трансферов используется монада Rea env e a
(Reader
над env
, State
над таблицей из a
) и
функция performTransfer
(она возвращает количество произведенённых трансферов, т.е. количество запусков данного
трансфера для данной в монаде таблицы) или sequenceTransfers
:
performTransfer :: Eq e => Transfer env e -> Rea env e Int
sequenceTransfers :: Eq e => [Transfer env e] -> Rea env e ()
Монада Rea env e
разворачивается в IO
посредством runRea
:
-- | Run a Rea computation (i.e. a game turn)
runRea :: Rea env e a -> env -> Database e -> (a, Database e)
То, что я называю здесь таблицей (где содержатся сущности e
) имеет тип Database e
и представляет собой IntMap
из
пакета containers
.
Пример⌗
В качестве примера рассмотрим модель простенькой RTS-подобной игры, где юниты перемещаются к заданным в списке позициям (waypoints), атакуют соседей и перезаряжают орудия.
Преамбула⌗
Модули:
{-# LANGUAGE TemplateHaskell #-}
module Main where
import Rea
import Control.Monad
import Data.Maybe
import Linear
import Lens.Micro
import Lens.Micro.TH
Объявление типов данных, генерация линз⌗
Мы объявляем тип сущностей (юнитов), а также тип окружения (read-only environment типа игровой карты, которая затем передаётся во все предикаты и отношения). Для сущности мы обязательно генерируем линзы.
type Env = ()
data Unit
= Unit
{ _unitName :: String
, _unitPos :: V2 Double
, _unitWps :: [V2 Double]
, _unitAttack :: Int
, _unitReloading :: Int
, _unitDamage :: Int
}
deriving (Show, Eq)
makeLenses ''Unit
Описание логики игры в терминах трансферов⌗
Далее мы продумываем и описываем логику игры в терминах трансферов. В данном случае я выделяю следующие транфсеры: движение, обновление текущего списка waypoints, огонь и перезарядка.
move :: Transfer Env Unit
move =
cx
(\_ u -> not (u ^. unitWps . to null))
unitPos
(\_ u ->
let wp = u ^. unitWps . to head
pos = u ^. unitPos
d = distance pos wp
v = normalize $ wp ^-^ pos
in
if d <= 1 then wp else u ^. unitPos + v
)
updateWps :: Transfer Env Unit
updateWps =
cx
(\_ u -> let mbwp = u ^. unitWps . to listToMaybe
in
case mbwp of
Nothing -> False
Just wp -> u ^. unitPos == wp
)
unitWps
(\_ u -> u ^. unitWps . to tail)
fire :: Transfer Env Unit
fire =
tmx
(\_ u1 u2 ->
distance (u1 ^. unitPos) (u2 ^. unitPos) < 3
&& u1 ^. unitAttack > 0
&& u1 ^. unitReloading == 0
)
unitAttack
unitDamage
(\_ u1 u2 ->
(u1 ^. unitAttack - 1, u2 ^. unitDamage + 1)
)
talways
unitReloading
(\_ _ -> 3)
reload :: Transfer Env Unit
reload =
cx
(\_ u -> u ^. unitReloading > 0)
unitReloading
(\_ u -> u ^. unitReloading - 1)
Использование линз существенно упрощает код.
Компоновка “хода игры” в монаду Rea env e⌗
Далее мы компонуем трансферы в последовательность игрового хода, получая монаду Rea env e
:
logic :: Rea Env Unit ()
logic = do
sequenceTransfers [fire, reload, move, updateWps]
Запуск игровой логики⌗
И запускаем игру с некоторыми начальными значениями (таблица и окружение):
env = ()
db = fromList
[
Unit "unitA" (V2 0 0) [V2 10 10] 5 0 0
, Unit "unitB" (V2 10 10) [] 5 0 0
, Unit "unitC" (V2 0 0) [V2 10 10, V2 40 20] 5 0 0
, Unit "unitD" (V2 1 1) [] 5 0 0
]
main :: IO ()
main =
execRea logic env db
Производительность⌗
Вот производительность библиотеки rea
для 1000 юнитов данной игры, постоянно находящихся рядом друг с другом для атаки
и следующих вместе к одной локации.
benchmarking 1000
time 1.774 s (1.388 s .. 2.205 s)
0.989 R² (0.988 R² .. 1.000 R²)
mean 2.021 s (1.892 s .. 2.095 s)
std dev 124.3 ms (36.29 ms .. 166.8 ms)
variance introduced by outliers: 19% (moderately inflated)
Замерено с помощью замечательного пакета criterion.