Некоторое время назад, вдохновившись прочтением SICP, я написал пару своих реализаций интерпретаторов лиспоподобного языка со строгой семантикой, добавил десктопный GUI, консольный интерфейс, написал на нем Тетрис и много чего еще, и опубликовал пару статей на Хабр об этом.

Недавно я добавил возможность широкой аудитории познакомиться с данным языком — написал REPL-ботов для следующих мессенжеров: IRC, Telegram, Slack, Gitter. Боты располагаются на специально созданных для них каналах, но в большинстве случаев их можно добавлять/приглашать на другие каналы и вести с ними личную переписку. Такой формат позволяет проводить текстовые онлайн-доклады на тему основ функционального программирования, сопровождая их демонстрацией интерпретатора в реальном времени.

Конечно, графические окна с анимацией можно создавать только в десктопном варианте приложения. Поэтому для большего раскрытия возможностей языка и РЕПЛа я написал текстовую реализацию игры Лабиринт, в которую могут играть с ботом любое количество человек. Подробности и немного лирики под катом.

Описание и правила игры


Когда я был школьником (а это были 80-е годы прошлого века), компьютеры и интернет были, мягко говоря, не так широко распространены и доступны, как сейчас. Поэтому мы с друзьми-одноклассниками играли в нормальные детские игры ручкой на листе бумажки — морской бой, точки и т.п. В числе прочих была и такая игра, которую мы называли Лабиринт.

Правила таковы: выбирается ведущий, который загадывает карту и рисует ее у себя на бумажке, никому не показывая. Карта представляет собой прямоугольную сетку n на m клеток, клетка может быть пустой, в клетке может быть яма — объект типа телепорта — при попадании в одну клетку ямы игрок перемещается в другую а ведущий называет игроку номер ямы, на карте есть реки — они текут только в соседние по сторонам клетки (по диагонали нельзя), при попадании в реку игрок переносится в конец данной реки а ведущий сообщает игроку факт заплыва без указания номера реки. При попытке пойти в сторону стены ведущий говорит об этом. Параметры карты, количество рек/ям и длина рек всем игрокам известны. В начале игры каждый игрок сообщает ведущему свои желаемые стартовые координаты, ведущий отвечает судьбу персонажа (пустая клетка, река, яма-1) и далее игроки делают свои ходы по очереди, произнося один из 4 возможных вариантов: влево/вправо/вверх/вниз, а ведущий двигает соответствующие игрокам фишки по карте с учетом перемещений по рекам и телепортаций по ямам. Можно дополнять правила внутренними стенами, которые можно взрывать гранатами, пополняемыми в специальных клетках-арсеналах, вводить в игру миссии поиска клада и выхода, разнообразные новые объекты — типа зеркальной клетки, при попадании на которую ведущий объявляет ее как пустую клетку, но при ходе с которой ведущий молча перемещает игрока в сторону противоположную ходу игрока, и т.п.

Но даже минимальные базовые правила игры создают достаточный интерес и миссию — узнать карту! Это не так просто как кажется на первый взгляд. Можно слушать ответы ведущего себе и другим игрокам, накапливая информацию о кусочках карты, пытаясь склеить ее в единое целое. Но любая ошибка на этом пути чревата тем, что карта «не собирается», а в каком куске ошибка уже выяснить нельзя — и приходится перечеркивать всю имеющуюся информацию и начинать накапливать ее заново. Но когда удается выяснить карту, появляется (по крайней мере у меня) качественно новое ощущение — вместо случайных тыканий по стенам, заплывов по рекам и полетов по ямам, когда реальность в виде ответа ведущего постоянно разбивает твои иллюзии и прогнозы в пух и прах, и ты уже начинаешь подозревать его в ошибках, возникает ощущение полного просветления, гармонии и постижения дао — можно делать ходы осмысленно, зная их последствия, строить на этом тактику и стратегию игры (при наличии миссии), и вообще испытывать несравнимое удовольствие от соответствий реальности твоим представлениям о ней :)

В общем, настоятельно рекомендую попробовать — для примера, мы со старше-средним 9-летним сыном любим играть в нее на прогулках, безо всяких ручек и бумаги, просто по памяти — уровень поля 3*3, одной реки в 3 клетки и двух ям (остаются 2 пустые клетки) он уже решает с легкостью в уме, а 4*4 ему еще тяжело. Мы в старших классах комфортно чувствовали себя на поле 6*6 с адекватным набором объектов, а поля 8*8 не осиливали проходить до конца.

Немного про ботов


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

Для каждой комнаты/личной переписки создается отдельная сессия бота со своим пространством имен, которое может изменяться в процессе запросов/ответов — стандартный формат REPL (read-eval-print loop). При засыпании приложения вся пользовательская информация стирается, и при пробуждении заново создаются сессии и в каждую загружается стандартная библиотека. Внутри каждой комнаты пространство глобальное имен общее для всех пользователей, но команда каждого пользователя запускается в отдельном потоке. Ограничения на время выполнения команд нет, но пользователь не может запустить новую команду-поток до завершения предыдущего. Для принудительного прерывания текущего потока служит команда бота !. Это позволяет все участникам канала иметь доступ к общему мутабельному состоянию, и в то же время запускать циклические процессы внутри лямбд со своим локальным состоянием.

Отдельно хочу отметить одну проблему, возникшую при реализации чат-бот интерфейса. Если при вычислении по команде print выводить в чат результат немедленно, то возможно написание спам-бомб с бесконечно зацикленным выводом, захламляющем общий чат. Поэтому было принято решение печатать «в стол» — в отдельной переменной накапливать все результаты печати, и в конце вычислений выводить их вместе с результатом вычисления. Но тогда пропадает возможность запустить интерактивный циклический процесс, при котором бот не заканчивая вычисление пишет в чат промежуточный вывод, ожидая ввода от пользователя в блокирующем режиме. В результате я придумал следующий вариант, который меня стопроцентно устраивает по всем параметрам — функция блокирующего вычисление ввода от пользователя read тоже теперь умеет печатать — но в отличие от print, она печатает не «в стол», а сразу в окно чата, обеспечивая интерактивность взаимодействия. А спам-бомба не работает, потому что после печати read ждет ввода пользователя в блокирующем режиме, поэтому даже при зацикливании бесконечных портянок текста без подтверждения пользователя не будет.

Про реализацию игры


Весь код игры состоит из двух функций — генерации игрового поля, и начала игры на сгенерированном поле. Текст функций довольно объемен, и например в Telegram у меня не получилось загрузить его одним сообщением — сообщение разбилось на 2, бот реагировал на них по отдельности, разумеется синтаксическая и семантическая целостность кода потерялась. Но выход прост — загружайте каждую функцию отдельным сообщением :)

Разумеется, это не касается IRC, где сильные ограничения (максимум 512 символов и отсутствие многострочных сообщений) не позволяют загружать боту сколь-нибудь нетривиальные куски кода. Но в остальных трех перечисленных мессенжерах все работает — и вы можете видеть результаты в стартовой картинке статьи. Собственно, после загрузки функций в REPL, начало игры может выглядеть так:

join (new-field 5 5 3 4 2) 2 3

Это значит — запустить игру на недоступном никому больше поле 5*5 с 3 реками длиной 4, 2 ямами и стартовой клеткой 2 строка/3 столбец. Или так:

def common-field (new-field 5 5 3 4 2)

с последующим вызовом

join common-field 2 3

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

Описание команд управления выводится в чат при начале игры. Тексты функций приведены ниже:

Код функции генерации нового поля
; генератор поля - передаем кол-во строк, столбцов, рек, длину рек и кол-во ям ;
(defn new-field (max-r max-c rivers-count river-length holes-count)

    ; генератор случайных чисел в заданном диапазоне 0 - (n-1) ;
    (def random-int-object (java (class "java.util.Random") "new"))
    (defmacro random-int (n) java random-int-object "nextInt" n)

    ; взять случайный элемент списка: (1 2 3 4 5) -> 2 ;
    (defn list-rand (l) cond (null? l) nil (list-ref (random-int (length l)) l))

    ; отщепить случайный элемент от списка: (1 2 3 4 5) -> (2 (1 3 4 5)) ;
    (defn get-rand-cell (l)
        (def c (list-rand l))
        (cond (null? l) nil (cons c (filter (lambda (x) not (eq? x c)) l) nil) ))

    ; дать свободные клетки поля, соседние данной по горизонтали/вертикали ;
    (defn get-free-neighbours (p free-cs)
        (defn good (p) and (and (<= 1 (car p) max-r) (<= 1 (cadr p) max-c)) (elem p free-cs))
        (def neighbours (map (lambda (x) zipwith + p x) '((0 -1) (0 1) (-1 0) (1 0)) ))
        (filter good neighbours) )

    ; добавить очередную клетку к реке, отщепив ее от свободных: ;
    ; ((7 3) (1 2 4 5 6)) -> ((4 7 3) (1 2 5 6)) ;
    (defn get-next-river-cell (river-free-cs)
        (def river (car river-free-cs) free-cs (cadr river-free-cs))
        (def cs (cond (null? river) free-cs (get-free-neighbours (car river) free-cs)))
        (cond (null? cs) nil
            ((def c (list-rand cs))
             (cons (cons c river) (filter (lambda (x) not (eq? x c)) free-cs) nil)) ))

    ; набрать реку заданной длины: (() (1 2 3 4 5 6 7)) -> ((1 4 7 3) (2 5 6)) ;
    (defn get-river (len river-free-cs)
        cond (= 0 len) river-free-cs
             (null? state) nil
             (get-river (- len 1) (get-next-river-cell river-free-cs)))

    ; попытаться набрать реку заданной длины ограничивая число неудачных попыток ;
    (defn try-get-river (trys len river-free-cs)
        (def river (get-river len river-free-cs))
        (cond (= 0 trys) nil (null? river) (try-get-river (- trys 1) len river-free-cs) river) )

    ; добавить очередную реку к списку рек, уменьшая список свободных клеток ;
    (defn add-river (rivers-free-cs)
        (def rivers (car rivers-free-cs) free-cs (cadr rivers-free-cs))
        (def river (try-get-river 50 river-length (cons nil free-cs nil)))
        (cond (null? river) nil (cons (cons (car river) rivers) (cadr river) nil) ))

    ; добавить очередную яму к списку ям, уменьшая список свободных клеток ;
    (defn add-hole (holes-free-cs)
        (def holes (car holes-free-cs) free-cs (cadr holes-free-cs))
        (cond (null? (cdr free-cs)) nil
            ((def a (get-rand-cell free-cs) b (get-rand-cell (cadr a)))
            (def hole (cons (car a) (car b) nil))
            (cons (cons hole holes) (cadr b) nil) )))

    (def all-cells (concat (map
        (lambda (r) map (lambda (c) cons r c) (list-from-to 1 max-c))
        (list-from-to 1 max-r) )))

    (def rivers-free-cs (ntimes rivers-count add-river (cons nil all-cells nil)))
    (def holes-free-cs (ntimes holes-count add-hole (cons nil (cadr rivers-free-cs) nil)))
    (def rivers (car rivers-free-cs) holes (car holes-free-cs))

    (cond (or (null? rivers-free-cs) (null? holes-free-cs))
              ((print "Не удалось создать карту") nil)
          (make '((max-r max-c) rivers holes)) )
)

Код функции начала игры
; начало игры - передаем готовое поле и координаты стартовой точки ;
(defn join (field row col)

    (match field '((max-r max-c) rivers holes))

    ; строка - красивое представление поля с указанием текущей позиции игрока ;
    (defn show-field (cur-p)
        (def rows (map
            (lambda (r) map (lambda (c) cons r c) (list-from-to 1 max-c))
            (list-from-to 1 max-r) ))

        (def h-divider (foldl ++ "+" (replicate max-c "+----")))

        (def alphabet '("" "A" "B" "C" "D" "E" "F" "G" "H" "I" "J"))

        (defn show-row (row) foldl
            (lambda (x a) ++ a (show-point x) (cond (eq? x cur-p) "#" " ") "| ") "| " row)

        (defn show-point (p)
            (def rr (get-by-p p rivers (lambda (oi ei) ++ (list-ref oi alphabet) ei)))
            (def rh (get-by-p p holes  (lambda (oi ei) ++ "." oi)))
            (cond (not (null? rr)) rr (not (null? rh)) rh "  ") )

        (defn get-by-p (p objects v)
            (defn go (l i)
                (def ei (+ 1 (elem-index p (car l)) ))
                (cond (null? l) nil (> ei 0) (v i ei) (go (cdr l) (+ 1 i)) ))
            (go objects 1))

        (foldl (lambda (x a) ++ a \n (show-row x) \n h-divider) h-divider rows) )

    ; пара тривиальных функций, которым место в стандартной библиотеке ;
    (defn elem-index (e l)
        (defn go (l i) cond (null? l) -1 (eq? e (car l)) i (go (cdr l) (+ 1 i)))
        (go l 0))

    (defn last (l) cond (null? (cdr l)) (car l) (last (cdr l)) )

    ; получение второй координаты ямы по первой ;
    (defn co-hole (p hole)
        (def a (car hole) b (cadr hole)) (cond (eq? p a) b (eq? p b) a p) )

    ; обработка команд пользовательского ввода ;
    (defn user-input (p show-flag comment)
        (def c (cond show-flag (read (show-field p) \n comment) (read comment)))
        (cond (eq? c 'a) (move p show-flag "влево" 0 -1)
              (eq? c 'd) (move p show-flag "вправо" 0 1)
              (eq? c 'w) (move p show-flag "вверх" -1 0)
              (eq? c 's) (move p show-flag "вниз" 1 0)
              (eq? c 'show) (user-input p (not show-flag) "")
              (eq? c 'quit) "игра прервана"
                         (user-input p show-flag "неверная команда") ))

    ; перемещение игрока в указанном направлении и снова вызов пользовательского ввода ;
    (defn move (p-pred show-flag dir dr dc)
        (def r (+ dr (car p-pred)) c (+ dc (cadr p-pred))
             in-field (and (<= 1 r max-r) (<= 1 c max-c)) p (cons r c))
        (def rr (get-by-p p rivers (lambda (oi river) cons (last river) "река")))
        (def rh (get-by-p p holes  (lambda (oi hole) cons (co-hole p hole) (++ "яма " oi))))
        (cond (not in-field)   (user-input p-pred show-flag (++ dir " - стена"))
              (not (null? rr)) (user-input (car rr) show-flag (++ dir " - " (cadr rr)))
              (not (null? rh)) (user-input (car rh) show-flag (++ dir " - " (cadr rh)))
                               (user-input p show-flag (++ dir " - пусто")) ))

    ; поиск переданной позиции в списке объектов (рек или ям), возвращает примененный визитор ;
    (defn get-by-p (p objects v)
        (defn go (l i) cond (null? l) nil (elem p (car l)) (v i (car l)) (go (cdr l) (+ 1 i)) )
        (go objects 1))

    ; собственно вызов цикла пользовательского ввода с указанной стартовой точки ;
    (read "a d w s - влево/вправо/вверх/вниз, show - показывать/скрывать карту, quit - выход" \n "введите что-нибудь для начала игры")
    (move (cons row col) false "старт" 0 0)
)

ЗЫ этот код не претендует на защиту от некорректного ввода, хотя это совсем несложно сделать. Более того, можно добавить автоматический контроль строгого порядка ходов участников, по порядку их присоединения к общему полю. Можно добавить все, что только фантазия подскажет! Но данный код я написал для примера за несколько часов, и не стал переусложнять его логикой. Единственно, что я хотел, так это написать в максимально функциональном стиле, без мутабельных состояний и переменных, отклонившись от чистого шелковистого ФП только в 2 моментах ввод/вывод по ходу вычислений и генерация случайных чисел при создании поля — но можно считать, что мы живем в IO монаде, и никакого криминала нет :) Хотя конечно можно было бы использовать встроенные мутабельные java-коллекции, ArrayList/Map/HashMap и т.п, никто не мешает. Но на этом примере я хочу донести простую мысль — что вы сами можете изменять его или вообще писать свои программы или свои игры, и запускать их онлайн в чатах :)

ЗЗЫ стартовая страница приложения, запускающая ботов: liscript.herokuapp.com

Все впечатления, советы, мнения, пожелания и т.п. можете озвучивать в любом из общих домашних каналов ботов во всех мессенжерах. Ну, кроме IRC — там при выходе последнего онлайн-пользователя канал удаляется как таковой вместе со всей историей сообщений.
Поделиться с друзьями
-->

Комментарии (0)