Pull to refresh

Подходы к созданию скриптового языка описания настольных игр

Reading time9 min
Views20K
Так уж случилось, что игры я писал лишь для себя, и профессионально этим никогда не занимался.
А вот опыт писать DSL (Domain Specific language) для уменьшения рутины написания совершенно разного кода хоть какой-то есть.
Именно этим и хочется поделится: как упорядочить необъятное.



Наш хороший хабр-юзер GlukKazan пишет много статей о том какие есть замечательные продукты для создания различных досочных игр. Такие как Zillions of Games и Axiom Development Kit.
Однако эти программы не универсальны. И всегда хочется улучшить их. Но данные продукты не свободны, поэтому приходится писать программный продукт заново.
GlukKazan работает над открытым проектом Dagaz, о чём делится отличными статьями (например тут: Dagaz: Новое начало).

Итак, предположим, мы хотим создать универсальный игровой движок для настольных игр, и его основой мы хотим видеть скриптовый язык, который помогает растолковать движку правила игры.
Каким мы хотим его видеть?
1) Язык должен быть универсальным, насколько можно, дабы описать почти любые правила игры.
2) Тем не менее, язык должен быть как можно проще, минимум конструкций.
3) Описание правил должны быть легки для чтения игроделу и для написания своих игр
4) Для большинства случаев игры можно писать, дополняя/изменяя уже написанные
5) Коммуникация (АПИ) со скриптом должна быть насколько простой, насколько это возможно. Так, что бы можно легко писать ботов и ИИ.
На первый взгляд кажется, что потраченные усилия вообще никому не нужны будут, поскольку рутину не уменьшить, проще писать игры сразу готовыми.
Но это не так.
Всё куда проще!

Демиурги и чёрная коробка


Что ж, представим, что мы демиурги, и способны, нет, уже написали этот скриптовый язык. Да, да, уже. Давайте проследим, что этот язык может, и что умеет делать.

Не ставьте ограничений там, где их нет


Например, проект Dagaz (да и его предшественники Axiom и ZoG) делает акцент на досочных играх. Однако даже человеку объяснить чем досочная игра отличается от недосочной — достаточно сложно. Что уж говорить, описать это в точных определениях на каком-то языке программирования ещё сложнее.
Поэтому первое и главное правило — не надо ставить ограничений там, где их нет!
Мы будем рассматривать не досочные игры, а настольные.
Давайте глянем на следующий список, которые мы хотим описать и играть при помощи нашего движка.
  • Шахматы
  • Сплют
  • Каркассон
  • Точки
  • Пятнашки
  • Домино
  • Перекрашивание кристаллов
  • Дурак

Они ну очень разные на первый взгляд. Что же их всех объединяет? Неужели хоть что-то?
Да. Объединяет их то, что
  1. В один момент времени всегда ходит 1 игрок
  2. Почти всегда игрок может думать бесконечно долго (отдельно рассмотрим, когда будут ограничения по времени на ход)
  3. Каждый раз существует ограниченное количество возможных действий. Игра Точки является исключением (ибо точку можно ставить где угодно на бесконечной доске). Для удобства мы несколько урежем Точки, а не изменим движок

Всё, только эти ограничения на движок!
Итого, по сути мы хотим иметь движок, который должен уметь спрашивать 2 основных вопроса: кто сейчас ходит, какие возможные действия он может сделать.
IN:  who
OUT: Player1

IN:  actions
OUT: [SomeAction1, SomeAction2 Param21, ...]

И движок должен уметь совершать одно действие — совершить выбранное действие.
action SomeAction3

Ну, может ещё одно действие — выбрать предварительное действие (например, если закончится отведённое для хода время, а ход ещё не сделан, дабы не заканчивать игру или выбирать случайное действие, можно выбрать предварительное действие в таком случае).
preaction SomeAction5

Не надо ставить больше никаких ограничений, ведь их всё равно кому-то захочется обойти.

Думайте шире


А как же быть, если мы пишем Покер? Да и в Дурака можно подкинуть карту, а можно не подкинуть. Сказать Пас, или выбрать свою масть — это тоже действие. Выставить поле в Каркассоне — это тоже действие.
А тетрис куда? Вот тетрис, наверное, будет делать очень сложно, ибо это реал-таймовая игра, и вряд ли надо. Доработать движок можно легко, но не актуально.

Не кастрируйте скриптовый язык


Не рекомендую следовать принципу — я получу все данные из скрипта и буду моделировать в движке. Ибо таким образом, проще использовать .ini файл как конфигурацию, а не городить скриптовый язык, так как толку будет столько же.
А как взять карту для Дурака, когда отбился? Просто следующим будем снова ходить мы и будет лишь 1 вариант хода — взять карту. Если что можно добавить значения действия авто — то бишь не спрашивать у игрока какой вариант выбирать.
IN:  who
OUT: Player3
# атакует 8й  бубна (хотя лучше было бы двумя 10ками - бубовой и чирвой)
IN:  actions
OUT: [Attack 8Diamonds, Attack 10Diamonds 10Hearts, ...]
IN:  action Attack 8Diamonds
OUT: action Attack 8Diamonds
IN:  who
OUT: Player4
# забрать со стола, перевести или отбиться? Перевёл
IN:  actions
OUT: [TakeTable, PassShowOnly QueenDiamonds, Defend QueenDiamonds, Pass 6Diamonds, PassShowOnly 6Diamonds,...]
IN:  action Pass 6Diamonds
OUT: action Pass 6Diamonds
IN:  who
OUT: Player1
# предлагает отбить королём или тузом, забрать стол или перевести
IN:  actions
OUT: [TakeTable, Defend KingDiamonds 6Diamonds, Defend KingDiamonds 8Diamonds, Defend AceDiamonds 6Diamonds,  Defend AceDiamonds 8Diamonds,  PassShowOnly KingDiamonds, Pass KingDiamonds, PassShowOnly AceDiamonds, Pass AceDiamonds]
IN:  action Defend AceDiamonds 6Diamonds
OUT: action Defend AceDiamonds 6Diamonds
IN:  who
OUT: Player3
# предлагает подкинуть 3му игроку
IN:  actions
OUT: [Pass, Attack AceHearts]
IN:  action Pass
OUT: action Pass
IN:  who
OUT: Player2
# предлагает спасовать, даже автоматически, нечего подкидывать
IN:  actions
OUT: [auto Pass]
IN:  action Pass
OUT: action Pass
IN:  who
OUT: Player4
# пасуем
IN:  actions
OUT: [auto Pass]
IN:  action Pass
OUT: action Pass
IN:  who
OUT: Player1
# предлагает отбить королём или забрать всё
IN:  actions
OUT: [Defend KingDiamonds, TakeTable]
IN:  action Defend KingDiamonds
OUT: action Defend KingDiamonds
IN:  who
OUT: Player3
#берёт из колоды 1 карту, кто ходил и не хватает
IN:  actions
OUT: [auto Take AceHearts]
IN:  action Take AceHearts
OUT: action Take AceHearts
IN:  who
OUT: Player1
#берёт из колоды 2 карты кто отбивался
IN:  actions
OUT: [auto Take 6Hearts 10Clubs]
IN:  action Take 6Hearts 10Clubs
OUT: action Take 6Hearts 10Clubs

Пусть движок ничего не знает об игре. Он всего лишь конвейер для запросов и красивого отображения. Не более, но и не менее.

Куда же без настроек


Насколько бы простым не была коммуникация со скриптом, без настроек обойтись никак не удастся.
В Дурака можно играть вдвоём, втроём, вчетвером, впятером и даже вшестером. Можно играть каждый сам за себя, 2х2, 3х3.
Нам необходимо посылать настройки. Добавляем ещё одну команду. Ок, две, надо же и проверить состояние
set players = 4

set groups = 2x2

set startFrom = Player3

IN:  get name
OUT: name = 15-puzzle


Игра прежде всего


Необходимо помнить, что мы создаём игры. А любые игры объединяет то, что
  • Игра может быть не начата
  • Игра может идти
  • Игра может быть завершена и иметь итоговый результат

Создадим ещё несколько команд, которые отражают новые способности — загрузить игру, начать игру, узнать результат игры
IN:  load /path/chess.game
OUT: load /path/chess.game

IN:  start
OUT: start

IN:  result
OUT: Finished ; Win Player1; Details Player1 78, Player2 38
OUT: Loaded /path/chess.game
OUT: Started


Отделить мух от котлет. Язык визуализации


Не стоит гнаться за излишней универсальностью.
Будем помнить, что ботам визуализация по барабану, а вот человек очень придирчив, и хочет видеть очень красивую картинку.
Главный вывод из этого — языки визуализации и языки обмена сообщениями в общем случае различны. Гнаться за универсальностью скриптового языка не стоит, задачи ведь разные.
Язык сообщений визуализации не надо подгонять под язык обмена.
Пусть вы создали, что на каждый необходимый для визуализации объект есть формат визуализации (далее ФВ), который описывает какую картинку (или какие картинки) следует отобразить, в какой части экрана, что с чем наложится.
Нам вначале необходимо отобразить всё. Значит, необходима команда визуализация ситуации, которая выдаёт список объектов в формате ФВ.
Ведь мы должны увидеть шахматную доску с фигурами, или что нам раздали для Дурака.
IN:  view
OUT: [ (Piece1, ФВ), (King, ФВ), ..... ]

Кстати, визуализировать зачастую надо то, что не фигурирует в качестве фигур в самой игре — например для Дурака надо отобразить количество закрытых карт у соперников, а так же колоду.
Кроме того, необходимо знать как визуализировать возможные ходы.
То есть должна быть команда визуализировать действия, который содержит список всех возможных действий, и к каждому из них в одном из двух вариантов новое описание: полное или частичное (с добавлением необходимых фигур отображения, и убирания фигур из имеющихся).
Например, в Дурака необходимо убрать из колоды выбранную карту, зато эта карта должна появиться на столе.
IN:  view actions
OUT: [(Attack 8Diamonds, Diff [remove Card5, add (Card5, ФВ), ... ]), ... ]

А в шахматах выбранная фигура должна стать «выбранного» цвета, и на месте выбранной клетки появится эта же фигура. В случае атаки, к тому же, должна исчезнуть битая фигура.
Кроме того, необходимо знать, когда надо отображать то или иное возможное действие.
Собственно, либо в предыдущую команду засунуть, либо новую команду добавить.
IN:  view actions
OUT: [(Attack 8Diamonds, Diff [remove Card5, add (Card5, ФВ), ... ],   OnChoose Card5), 
(Pass, Diff [add PassWord],   OnChoose PassLabel), 
... ]

Язык искусственного интеллекта

Модули ИИ желательно писать отдельно, но интегрировать в общий скрипт.
Разговор ИИ в принципе будет не сильно сложный.
IN:  analize
OUT: [75 Pass, 44 Attack 6Diamonds, 59 Attack 8Diamonds]
IN:   analize quick
OUT: [10 Pass, 5 Attack 6Diamonds, 20 Attack 8Diamonds]

Реализовать подобное куда сложнее, чем описать. Вполне возможно понадобятся форки.

Обоюдное понимание


Необходимо, чтобы движок понимал бы, совершает ли он ошибки в разговоре со скриптом, или нет. Связь должна быть обоюдной, особенно когда взаимодействие представляет клиент-серверное приложение.
По сути необходимо одна команда: последняя принятая команда.
IN:  it
OUT: result

Этой же командой следует отвечать и тогда, когда ответ не важен. То есть установках, принятых действиях.
А то кто-то поставил лимит времени на обдумывание, игрок проспит это время, походит, а движок уже походил случайно за игрока.
Не забываем, что и движок может ответить ошибкой.
IN: action TakeTable
OUT: ERROR action is out list

IN:  who
OUT: ERROR game result is Finished


Не боги горшки обжигают


Что же, мы достаточно много проследили за тем, что должен уметь наш скрипт, а на что нам наплевать.
Вся, не, ВСЯ логика ложится на скрипт. Так и только так мы добьёмся полной универсальности любых настольных игр.
Любой компромисс тут ведёт к ограничениям, из-за которых не раз и не два придётся плясать с бубнами.
Но мы же хотели лёгкий, простой язык! А нам предлагают всё-всё положить на игроделов.
Если нельзя, но очень хочется, то можно! Сложно должно быть программисту, а не игроделу. И так будет.

Будьте игроделами


Будьте немного игроделом сами!
Одно из самых удивительных свойств Си, который меня до сих пор поражает, это то, что тип string — библиотечная функция.
Не надо ждать, пока игроделы придумают что такое доска, поле или колода карт. Напишите на скриптовом языке сами эти понятия.
Обычные люди просто напишут, возмём доску, и создадим тут такие поля.
Именно поэтому и необходимо, чтобы все написанные скрипты можно было поверх изменять.
Захотелось для игры не статическую доску, а динамическую, пожалуйста, изменяем доску так, что её состояние будет проверятся каждый раз на ход.
# DynamicBoard
import StaticBord

let board {
  constuctor {let board = prevous board}
  on_player_change {let board = recalculate}
}


Кесарю кесарево, а Богу Богово


Даже не смотря на кучу написанных библиотек самим программистом, скриптовый код станет облегчённым, но не лёгким.
Как убрать ненужные куски кода?
Необходимо понять, что нам нужен подъязык, DSL этого скриптового языка, написанном на самом языке.
Звучит страшно, однако не всё есть чёрт, что малюют.
Для меня одной из самых красивых библиотек является парсер Parsec для Хаскеля. Это куда красивее реализации строк в Си.
Строки хоть планировали включать в язык, а вот парсер писать не планировали, когда составляли Хаскель.
По сути, там создали лишь средствами языка 2 уровня подъязыка для составления парсера (на самом деле там ещё есть не менее 4 дополнительных уровней).
1) Уровень токенов/символов. На этот малопонятный большинству уровень почти никто не лезет писать свои функции, библиотечных функций хватает с головой.
Например,
конец ли строки — eof
взять 1 токен/символ — anyToken
Попробовать парсер, если упадёт, сделать вид, что он не потреблял токены — try p
Или: попробовать парсер, если он упадёт без потребления токенов, применить второй парсер — p1 <|> p2
Посмотреть вперёд: попробовать парсер, сделать вид, что он не употребил токенов, если парсер удачный — lookAhead p
(в реальности там намного больше функций).
Комбинацией этих токенистических функций позволяет порождать очень сложные парсеры.
2) Уровень парсеров. Итак интуитивно понятный, дополненный целым зоопарком функций: такими как
много парсеров — many p
много, хотя бы 1 парсер — many1 p
парсер разделённый — p `sepBy` sepp
строчка — string
между парсерами — between p1 p2


Собственно, когда приходит юзер библиотеки, то видит, что «всё построено до нас», есть уже готовый конструктор, просто бери и собирай то, что тебе нужно и на том уровне, который тебя интересует.
То же самое касается и нашего языка. Не должен игродел думать как писать функцию фильтра, она должна быть там уже написана.
Просто бери и пользуйся, так должен говорить скриптовый язык своим юзерам!
Если хочу шашки, но чтобы в ячейку помещалось 1 или 2 шашки — надо мне пересоздать лишь описание ячейки.
Хочу квадратную доску — загружаю себе квадратную, хочу шестиугольную — загружу 6-угольную.
Надо мне, чтобы в зависимости от снятия пешек с доски уничтожались поля самой доски — делаем доску динамической и следим за уничтожением пешек.

Суп быстрого приготовления или нет предела совершенству


Мы все любим вкусно поесть, но вот вкусно готовить могут не все. Это требует умений и времени.
Игроделы такие любители супов.
Им надо ещё облегчить долю.
Необходим в нашем скиптовом языке ещё… мета-язык. Выдохните, просто шаблоны игр.
Шахмато-подобные-игры, Шашки-подобные-игры, дурак-подобные-игры, карточные-игры,…
Дайте возможность просто сконфигурировать готовые решения, а не заниматься программированием.
import ChessLikeGame;

let params = {
 previous params;
 players = 4;
 board_length = 75
 symmetric start positions = true;
 start_white_positions = [A1, C1,D1, ....]
}


Заключение


Что же, если посмотреть на наши хотелки вначале статьи, то можно ужаснуться, мы хотели иметь и супер-универсальный скриптовый язык, и одновременно быть по-сути конфигурированием готовых решений, легко менять логику написанных игр.
На самом деле мы поняли чего хотим, чего не хотим, поняли, что основную часть написанного кода надо писать на скриптовом языке, а движку оставить визуализацию и реакцию.

Нет никакой разницы между шахматами, Балдой и дураком.
Кто ходит? Игрок 1
IN:  who
OUT: Player1

Нет разницы между точками и пятнашками кристалл-перекрашиванием. Есть ограниченный выбор действий каждый раз.
# chess
action Castle King Rook2

# checkers
action Attack Men7 D2 F4

# splut
action Pull Troll E5 D5

# points
action Add 10-14

# carcassonn
action Put Title1Tree Place8-17

#15-puzzle
action Push 8

# dominos
action Put Title1-6 Place4-1 Left

# crystall painting
action Color Yellow

# durak
action New QuenHearts

Это просто надо понять, и тогда станет ясно, как это объяснить компьютеру.
Tags:
Hubs:
Total votes 23: ↑20 and ↓3+17
Comments30

Articles