Моделирование игровых объектов с помощью netwire

Я собираюсь написать игру в реальном времени на Haskell, используя netwire и OpenGL. Основная идея состоит в том, что каждый объект будет представлен проводом, который будет получать некоторое количество данных в качестве входных данных и выводить свое состояние, а затем я подключу все это к одному большому проводу, который получает состояние графического интерфейса в качестве входных данных. и выводит состояние мира, которое затем я могу передать средству визуализации, а также некоторую «глобальную» логику, такую ​​как обнаружение столкновений.

В одном я не уверен: как мне печатать провода? Не все сущности имеют одинаковые входные данные; игрок — единственная сущность, которая может получить доступ к состоянию ключа ввода, поиску ракет нужно положение их цели и т. д.

  • Одной из идей было бы иметь тип ObjectInput, который будет передаваться всем, но мне это кажется плохим, поскольку я могу случайно ввести зависимости, которые мне не нужны.
  • С другой стороны, я не знаю, было бы хорошей идеей иметь SeekerWire, PlayerWire, EnemyWire и т. д., поскольку они почти «идентичны», и поэтому мне пришлось бы дублировать функциональность в них.

Что я должен делать?


person Venge    schedule 03.02.2013    source источник
comment
Абстрагируйте части, которые почти идентичны, а не дублируют.   -  person luqui    schedule 03.02.2013
comment
@luqui Верно, я просто не знаю, как лучше всего это сделать. Все они будут иметь своего рода stepEntity, который дает им ввод и выводит их вывод (новое состояние объекта, новый провод, возможно, вспомогательный выход). Итак, как мне красиво абстрагироваться?   -  person Venge    schedule 03.02.2013
comment
трудно дать конкретный совет без подробностей (это требует многого - это трудная и полезная часть программного обеспечения). Лучшее, что я могу сказать сейчас, это поиграть с ним. Я бы начал с попытки придумать тип данных, параметризованный различными степенями свободы. Это может или не может работать хорошо.   -  person luqui    schedule 03.02.2013
comment
Закрыто как не по теме людьми, не разобравшимися в вопросе. Это очень хорошо связанный с программированием и очень практичный вопрос о том, как структурировать приложение Netwire! Здесь умеренность вредна.   -  person ertes    schedule 05.02.2013
comment
Я должен согласиться - хотя открытые вопросы о структуре приложения не подходят для SO, это четко определенный вопрос об использовании конкретной библиотеки. То, что сама библиотека занимается вопросами структуры приложения, в значительной степени не имеет значения.   -  person C. A. McCann    schedule 06.02.2013


Ответы (3)


Моноид запрета e — это тип исключений запрета. Это не то, что производит провод, но играет примерно ту же роль, что и e в Either e a. Другими словами, если вы объединяете провода по <|>, то типы выходов должны быть равными.

Допустим, ваши события GUI передаются по проводу через ввод, и у вас есть непрерывное событие нажатия клавиши. Один из способов смоделировать это — самый простой:

keyDown :: (Monad m, Monoid e) => Key -> Wire e m GameState ()

Этот провод принимает текущее состояние игры в качестве входных данных и выдает (), если клавиша удерживается нажатой. Пока клавиша не нажата, он просто тормозит. Большинству приложений на самом деле не важно, почему соединение блокируется, поэтому большинство проводов блокируется с помощью mempty.

Гораздо более удобный способ выразить это событие — использовать монаду чтения:

keyDown :: (Monoid e) => Key -> Wire e (Reader GameState) a a

Что действительно полезно в этом варианте, так это то, что теперь вам не нужно передавать состояние игры в качестве входных данных. Вместо этого этот провод просто действует как идентификационный провод, когда происходит четное, и запрещает, когда это не так:

quitScreen . keyDown Escape <|> mainGame

Идея состоит в том, что когда нажата клавиша выхода, проводник событий keyDown Escape временно исчезает, потому что он действует как проводник идентификации. Таким образом, вся проволока действует как quitScreen, предполагая, что она не тормозит себя. Как только клавиша отпущена, цепочка событий блокируется, поэтому композиция с quitScreen тоже блокируется. Таким образом, весь провод действует как mainGame.

Если вы хотите ограничить игровое состояние, которое может видеть провод, вы можете легко написать для этого комбинатор проводов:

trans :: (forall a. m' a -> m a) -> Wire e m' a b -> Wire e m a b

Это позволяет применять withReaderT:

trans (withReaderT fullGameStateToPartialGameState)
person ertes    schedule 03.02.2013
comment
Отличный совет! Я также счел полезным построить проводник на какой-то ограниченной монаде ввода-вывода. Таким образом, вы можете легко интегрировать существующие императивные модули в структуру Netwire. - person Dmitry Vyal; 06.02.2013
comment
Построение проводника на ограниченной монаде, а затем использование комбинаторов для получения исходной «базовой» монады — отличная идея! В одном я не уверен: как мне решить, что я хочу поместить в монаду в качестве Reader и что я хочу поместить в качестве входа в провод? - person Venge; 07.02.2013
comment
Кроме того, каковы были бы недостатки ограничения (MonadReader m) вместо явного указания монады Reader GameState? - person Venge; 07.02.2013
comment
Разница между использованием монады чтения и передачей вещей в качестве ввода заключается в том, что ввод является реактивным. Это выход другого провода. Концептуально никогда не бывает ошибкой принимать вещи в качестве входных данных. Однако, если входные данные не поступают по другому проводу, вы можете сэкономить время на наборе текста, используя считывающее устройство. Не стесняйтесь использовать ограничение MonadReader. В этом нет ничего плохого (за исключением, возможно, того, что вам, вероятно, понадобятся гибкие контексты). - person ertes; 07.02.2013
comment
Я действительно единственный, кто слишком глуп, чтобы понять, как написать keyDown из данной подписи? Я имею в виду, вы, кажется, подразумеваете, что вы можете как-то сделать что-то с монадой провода внутри самого провода, но я не понимаю, как это возможно? - person Cubic; 23.06.2013

Для этого есть очень простое и универсальное решение. Основная идея заключается в том, что вы никогда не объединяете источники разных типов. Вместо этого вы объединяете только источники одного типа. Хитрость, которая заставляет это работать, заключается в том, что вы заключаете вывод всех ваших разнообразных источников в алгебраический тип данных.

Я не совсем знаком с netwire, поэтому, если вы не возражаете, я буду использовать pipes в качестве примера. Что нам нужно, так это функция merge, которая берет список источников и объединяет их в один источник, который одновременно объединяет их выходные данные и завершает работу, когда все они завершены. Подпись типа ключа:

merge
 :: (Proxy p)
 => [() -> Producer ProxyFast a IO r] -> () -> Producer p a IO ()

Это просто говорит о том, что он берет список Producer значений типа a и объединяет их в один Producer значений типа a. Вот реализация merge, если вам интересно и вы хотите продолжить:

import Control.Concurrent
import Control.Concurrent.Chan
import Control.Monad
import Control.Proxy

fromNChan :: (Proxy p) => Int -> Chan (Maybe a) -> () -> Producer p a IO ()
fromNChan n0 chan () = runIdentityP $ loop n0 where
    loop 0 = return ()
    loop n = do
        ma <- lift $ readChan chan
        case ma of
            Nothing -> loop (n - 1)
            Just a  -> do
                respond a
                loop n

toChan :: (Proxy p) => Chan ma -> () -> Consumer p ma IO r
toChan chan () = runIdentityP $ forever $ do
    ma <- request ()
    lift $ writeChan chan ma

merge
 :: (Proxy p)
 => [() -> Producer ProxyFast a IO r] -> () -> Producer p a IO ()
merge producers () = runIdentityP $ do
    chan <- lift newChan
    lift $ forM_ producers $ \producer -> do
        let producer' () = do
                (producer >-> mapD Just) ()
                respond Nothing
        forkIO $ runProxy $ producer' >-> toChan chan
    fromNChan (length producers) chan ()

Теперь давайте представим, что у нас есть два источника ввода. Первый генерирует целые числа от 1 до 10 с интервалом в одну секунду:

throttle :: (Proxy p) => Int -> () -> Pipe p a a IO r
throttle microseconds () = runIdentityP $ forever $ do
    a <- request ()
    respond a
    lift $ threadDelay microseconds

source1 :: (Proxy p) => () -> Producer p Int IO ()
source1 = enumFromS 1 10 >-> throttle 1000000

Второй источник считывает три String с пользовательского ввода:

source2 :: (Proxy p) => () -> Producer p String IO ()
source2 = getLineS >-> takeB_ 3

Мы хотим объединить эти два источника, но их выходные типы не совпадают, поэтому мы определяем алгебраический тип данных, чтобы объединить их выходные данные в один тип:

data Merge = UserInput String | AutoInt Int deriving Show

Теперь мы можем объединить их в один список производителей одинакового типа, обернув их выходные данные в наш алгебраический тип данных:

producers :: (Proxy p) => [() -> Producer p Merge IO ()]
producers =
    [ source1 >-> mapD UserInput
    , source2 >-> mapD AutoInt
    ]

И мы можем проверить это очень быстро:

>>> runProxy $ merge producers >-> printD
AutoInt 1
Test<Enter>
UserInput "Test"
AutoInt 2
AutoInt 3
AutoInt 4
AutoInt 5
Apple<Enter>
UserInput "Apple"
AutoInt 6
AutoInt 7
AutoInt 8
AutoInt 9
AutoInt 10
Banana<Enter>
UserInput "Banana"
>>>

Теперь у вас есть комбинированный источник. Затем вы можете написать свой игровой движок, чтобы он просто читал из этого источника, сопоставлял шаблон на входе, а затем вел себя соответствующим образом:

engine :: (Proxy p) => () -> Consumer p Merge IO ()
engine () = runIdentityP loop where
    loop = do
        m <- request ()
        case m of
            AutoInt   n   -> do
                lift $ putStrLn $ "Generate unit wave #" ++ show n
                loop
            UserInput str -> case str of
                "quit" -> return ()
                _      -> loop

Давай попробуем:

>>> runProxy $ merge producers >-> engine
Generate unit wave #1
Generate unit wave #2
Generate unit wave #3
Test<Enter>
Generate unit wave #4
quit<Enter>
>>>

Думаю, тот же трюк сработает и для netwire.

person Gabriel Gonzalez    schedule 03.02.2013

В Elm есть библиотека для автоматов, которая, как мне кажется, похожа на то, что вы делаете.

Вы можете использовать класс типов для каждого типа состояния, к которому вы хотите иметь доступ. Затем реализуйте каждый из этих классов для всего состояния вашей игры (при условии, что у вас есть 1 большой толстый объект, содержащий все).

-- bfgo = Big fat game object
class HasUserInput bfgo where
    mouseState :: bfgo -> MouseState
    keyState   :: bfgo -> KeyState

class HasPositionState bfgo where
    positionState :: bfgo -> [Position] -- Use your data structure

Затем, когда вы создаете функции для использования данных, вы просто указываете классы типов, которые будут использовать эти функции.

{-#LANGUAGE RankNTypes #-}

data Player i = Player 
    {playerRun :: (HasUserInput i) => (i -> Player i)}

data Projectile i = Projectile
    {projectileRun :: (HasPositionState i) => (i -> Projectile i)}
person Zachary Kamerling    schedule 03.02.2013