Опубликован: 08.08.2015 | Доступ: свободный | Студентов: 281 / 38 | Длительность: 09:22:00
Лекция 8:

Игра "Жизнь"

< Лекция 7 || Лекция 8 || Лекция 9 >
Аннотация: Реализуется игра "Жизнь" Джона Конвея. Создается клеточное поле. Начальная конфигурация живых клеток формируется с помощью мыши. Поколения можно просматривать в двух режимах — пошаговом и автоматическом, организованным с помощью таймера. Рассматриваются случаи двух видов полей, ограниченного поля и "тора" — поля, в котором левая граница отождествляется с правой, а верхняя с нижней. Для хранения множества клеток используется красно-черное дерево.
Ключевые слова: поле, конфигурация

Описание игры. Создание клеточного поля

Ниже описываются правила игры "Жизнь" и создается клеточное поле.

Правила игры

Игра "Жизнь" ведется на клеточном поле. Каждая клетка поля находится в одном из двух состояний — живая или пустая. Игра состоит в итеративной смене одного поколения живых клеток другим по определенным правилам, которые называются правилами Конвея. На нулевом шаге итерации имеется некоторая начальная конфигурация живых клеток, которые составляют первое поколение. На последующих шагах состояние каждой клетки определяется количеством ее живых соседей. Соседними считаются клетки, имеющие хотя бы одну общую вершину.

Правила Конвея имеют вид:

  • если пустая клетка имеет ровно три живых соседа, то она становится живой;
  • если живая клетка имеет менее двух или более трех живых соседей, то она становится пустой;
  • в остальных случаях состояние клетки не изменяется.

Исследователи игры "Жизнь" обычно занимаются созданием и изучением поведения конфигураций, которые обладают теми или иными свойствами: не изменяются с течением времени; периодически воспроизводят самих себя, в том же самом месте или в некотором другом месте; периодически порождают новые живые клетки, и так далее.

Ниже создается клеточное поле, состоящее из 30 строк и 50 столбцов и, таким образом, содержащее 1500 клеток. Рассматриваются два варианта поля — ограниченное клеточное поле, и "тор". Тором называется поле, в котором противолежащие границы отождествляются. Если, например, живая клетка движется влево, то, после достижения границы окна, она появится справа.

Создадим проект lifeGame (MDI). Для игры необходимо создать окно, в котором размещается игровое поле, состоящее из клеток. Соответственно, ниже будут созданы диалоговое окно (Dialog) и два элемента управления — клетка (Draw Control) и игровое поле (Control). Клетки размещаются на игровом поле.

Создание клетки

Выделим корень дерева проекта, откроем с помощью команды меню New In New Package диалоговое окно CreateProject Item, выберем элемент Draw Control и назовем его cellControl.

Клетка обладает следующими свойствами:

  • состояние — пустая или живая;
  • номер строки;
  • номер столбца.

Соответственно, в интерфейсе cellControl следует объявить эти свойства, а также предикат, который устанавливает состояние клетки.

domains
    state = alive; empty.

properties
    i : integer.	% номер строки
    j : integer. 	% номер столбца
    state : state. 	% состояние

predicates
    setCellState: (state).
Листинг 8.1. Свойства клетки

В имплементации класса cellControl следует определить объявленные свойства и предикат, а также выбрать цвет для различных состояний клетки.

constants
    borderColor : color = color_WhiteSmoke.		% цвет границы
    emptyCellColor : color = color_Lavender.
    aliveCellColor : color = color_VioletRed.

facts
    i : integer := 0.
    j : integer := 0.
    state : state := empty.
    currentColor : color := emptyCellColor.

clauses
    setCellState(State):-
        state := State,
        currentColor := getColor(State),
        invalidate().

predicates
    getColor: (state) -> color.
clauses
    getColor(alive) = aliveCellColor:- !.
    getColor(_) = emptyCellColor.
Листинг 8.2. Определение основных параметров

Клетка создается пустой.

Добавим в редакторе окна cellControl обработчик событий PaintResponder. Ниже приведено его определение.

clauses
    onPaint(_Source, Rectangle, GDI):-
        GDI:setPen(pen(1, ps_Solid, borderColor)),
        GDI:setBrush(brush(pat_Solid, currentColor)),
        GDI:drawRect(Rectangle).
Листинг 8.3. Определение предиката onPaint

Создание диалогового окна

Подготовим поле для игры. С помощью диалогового окна CreateProject Item cоздадим элемент Control и назовем его gameControl. Закроем редактор окна gameControl.

Теперь создадим диалоговое окно gameDialog. В поле Title укажем название игры: "Игра Жизнь".

Поместим в окно следующие элементы управления (рис. 8.1 рис. 8.1):

  • пользовательский элемент управления (Custom Control):
    • Class: gameControl; Width: 500; Height: 300;
  • три кнопки (Push Button):
    • Name: step_ctl; Text: >;
    • Name: start_ctl; Text: Старт;
    • Name: clear_ctl; Text: Очистить;
  • флажок (Check Box):
    • Name: torus_ctl; Text: Тор.
Редактор окна gameDialog

Рис. 8.1. Редактор окна gameDialog

Размер окна следует увеличить (растянуть его с помощью мыши), для того чтобы игровое поле с указанными размерами помещалось в него полностью. Далее следует закрыть редактор окна.

Изменим определение предиката onShow в имплементации класса taskWindow так, как показано ниже. В результате при запуске приложения сразу откроется окно gameDialog. По умолчанию оно является модальным. Приложение будет закрываться одновременно с окном gameDialog.

clauses
    onShow(_Source, _CreationData):-
        _MessageForm = messageForm::display(This),
        _ = gameDialog::display(This).
Листинг 8.4. Определение предиката onShow класса taskWindow

Закрыть окно можно с помощью нажатия кнопки "Закрыть" ("крестик" в правом верхнем углу окна) или с помощью нажатия на кнопку Cancel.

Добавим в редакторе окна gameDialog обработчик события DestroyListener и обработчик нажатия на кнопку Cancel (Click Responder). Определяются они одинаково (см. ниже).

clauses
    onDestroy(_Source):-
        getParent():close().
Листинг 8.5. Определение предиката onDestroy
clauses
    onCancelClick(_Source) = button::defaultAction:-
        getParent():close().
Листинг 8.6. Определение предиката onCancelClick

Создание клеточного поля

Клеточное поле создается в классе gameControl (рис. 8.2 рис. 8.2).

Окно gameDialog

Рис. 8.2. Окно gameDialog

Изменим раздел open имплементации класса gameControl следующим образом:

open core, vpiDomains, cellControl

Ниже приведено определение основных параметров.

constants
    m : integer = 30.		      % число строк
    n : integer = 50.		      % число столбцов
    width : integer = 10.		  % ширина ячейки
    height : integer = 10.		% высота ячейки

facts
    gameDlg : gameDialog := erroneous.
    previousState : cellControl* := [].	 % предыдущее состояние
    cellTree : 
        redBlackTree::tree{tuple{integer, integer}, cellControl} :=
            redBlackTree::emptyUnique(). 
Листинг 8.7. Объявление основных параметров

Факт-переменная gameDlg хранит указатель на объект окна gameDialog. Факт-переменная previousState используется для хранения списка клеток, которые были живыми на предыдущем шаге. Факт-переменная cellTree предназначена для хранения индексов (номеров строк и столбцов) и указателей на объекты клеток поля. Для хранения используется красно-черное дерево.

Красно-черное дерево — это двоичное дерево поиска, которое пополнено пустыми листьями, так что каждая непустая вершина имеет двух потомков, и которое обладает следующими свойствами:

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

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

В языке Visual Prolog имеются классы redBlackTree, redBlackSet и другие, в которых реализованы средства, необходимые для хранения множеств в красно-черных деревьях.

В классе redBlackTree красно-черное дерево определяется как функция из множества ключей в множество значений. Вершины дерева хранят как ключи, так и значения. Поиск значений ведется по ключам. Когда создается дерево, то сначала берется пустое дерево, затем в него последовательно добавляются вершины так, что дерево всегда остается красно-черным (выполняются все необходимые свойства). Предикат empty/0 создает пустое дерево, в котором для одного ключа может храниться несколько значений в разных вершинах. Предикат emptyUnique/0 создает дерево, в котором разным ключам соответствуют разные значения; если для одного ключа добавляется несколько значений, то в дереве остается последнее.

В класcе redBlackSet дерево реализуется как множество уникальных элементов (ключи в нем отсутствуют).

Ключами в дереве cellTree являются пары индексов ячеек, а значениями — указатели на объекты этих ячеек. Создается дерево с уникальными ключами, в котором каждому ключу соответствует не более одной вершины (см. объявление факта-переменной cellTree).

Определение конструктора new/1 следует изменить так, как показано ниже.

clauses
    new(Parent):-
        new(),
        setContainer(Parent),
        %
        gameDlg := uncheckedConvert(gameDialog, Parent).
Листинг 8.8. Изменение определения конструктора new

Клеточное поле создает конструктор new/0. Определение этого конструктора также следует изменить (см. ниже).

clauses
    new():-
        userControlSupport::new(),
        generatedInitialize(),
        %
        foreach I = std::cIterate(m), J = std::cIterate(n) do
            Cell = cellControl::new(This),
            Cell:i := I,
            Cell:j := J,
            cellTree := redBlackTree::insert(cellTree, tuple(I, J), Cell),
            Cell:setSize(width, height),
            Cell:setPosition(J * width, I * height)
         end foreach.
Листинг 8.9. Создание прямоугольного клеточного поля

Предикат cIterate/1 недетерминированно возвращает для целого аргумента N значения от 0 до N – 1 включительно. Предикат insert/3 добавляет в красно-черное дерево новую вершину с заданными ключом и значением.

Правила Конвея

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

Создание начальной конфигурации живых клеток

Добавим в редакторе окна cellControl обработчик событий MouseDownListener. Ниже приведено его определение.

clauses
    onMouseDown(_Source, _Point, _ShiftControlAlt, _Button):-
        empty = state,
        !,
        setCellState(alive).
    onMouseDown(_Source, _Point, _ShiftControlAlt, _Button):-
        setCellState(empty).
Листинг 8.10. Изменение состояния клетки с помощью мыши

Далее в интерфейсе gameControl следует объявить предикаты clear и step. Предикат clear очищает клеточное поле, так что все клетки становятся пустыми. Предикат step выполняет один шаг итерации в соответствии с правилами Конвея.

predicates
    clear: ().
    step: ().
Листинг 8.11. Объявление предикатов в интерфейсе gameControl

Ниже приведено определение предиката clear.

clauses
    clear():-
        tuple(_, Cell) = redBlackTree::getAll_nd(cellTree),
            alive = Cell:state, 
            Cell:setCellState(empty),
        fail.
    clear().
Листинг 8.12. Очищение поля

Предикат getAll_nd/1 недетерминированно возвращает вершины красно-черного дерева в виде пар "ключ – значение". Предикат step будет определен позднее.

Поиск соседних клеток

Состояние клетки на очередном шаге итерации определяется количеством ее живых соседей. Ниже описывается предикат neighbor/1, который недетерминированно возвращает клетки, являющиеся соседними для заданной клетки. В определении этого предиката используется предикат isTorus, который будет определен позднее в классе gameDialog. Предикат isTorus возвращает одно из двух значений — true, в случае "тора", и false, в случае обычного ограниченного поля. Если его значение равно true, то количество соседей каждой клетки равно восьми. В противном случае клетки, лежащие на границе поля, имеют меньшее количество соседей.

predicates
    neighbor: (cellControl) -> cellControl nondeterm.
    neighbor: (integer I, integer J, boolean IsTorus, 
        integer NeighborI [out], integer NeighborJ [out]) nondeterm.
    f: (integer I, boolean IsTorus, integer N) -> integer nondeterm.
clauses
    neighbor(Cell) = Neighbor:-
        neighbor(Cell:i, Cell:j, gameDlg:isTorus(), I, J),
        Neighbor = redBlackTree::tryLookUp(cellTree, tuple(I, J)).

    neighbor(I, J, IsTorus, I, f(J, IsTorus, n)).
    neighbor(I, J, IsTorus, f(I, IsTorus, m), J).
    neighbor(I, J, IsTorus, f(I, IsTorus, m), f(J, IsTorus, n)).

    f(X, true, N) = (X + std::fromToInStep(-1, 1, 2)) mod N.
    f(X, false, _) = X - 1:-
        X > 0.
    f(X, false, N) = X + 1:-
        X < N - 1.
Листинг 8.13. Вычисление соседних клеток

Предикат tryLookUp/2 находит в дереве вершину с заданным ключом и возвращает значение. В данном случае по индексам клетки возвращается указатель на объект этой клетки.

Реализация шага итерации

Ниже реализуется один шаг итерации в соответствии с правилами Конвея.

clauses
    step():-
       % собираем в список живые клетки
        AliveCells = [C || 
            tuple(_, C) = redBlackTree::getAll_nd(cellTree), 
            alive = C:state],
       % проверяем, что он не совпадает со списком на пред. шаге
        AliveCells <> previousState,
        !,
       % находим соседей живых клеток
        Neighbors = [NC || Cell in AliveCells, NC = neighbor(Cell)],
       % разбиваем на группы одинаковых
        Groups = list::decompose(Neighbors, {(X) = X}),
       % находим для каждой клетки количество живых соседей
        NList = list::map(Groups, 
                    {(tuple(X, L)) = tuple(X, list::length(L))}),
       % применяем правила Конвея
        list::forAll(NList, {(tuple(Cell, N)):-
            if empty = Cell:state, 3 = N then
                Cell:setCellState(alive)
            elseif alive = Cell:state, (N < 2; N > 3), ! then
                Cell:setCellState(empty)
            end if}),
       % находим список клеток, у которых нет живых соседей
        RestCells = list::difference(AliveCells, Neighbors),
       % изменяем их состояние на пустое
        list::forAll(RestCells, {(Cell):- Cell:setCellState(empty)}),
       % запоминаем новое состояние
        previousState := AliveCells.
    step():-
       % если ничего не изменилось, то выключаем таймер
        gameDlg:stop().
Листинг 8.14. Реализация правил Конвея

Шаг итерации выполняется следующим образом. Формируется список AliveCells, содержащий живые клетки. Если он совпадает со списком живых клеток, полученных на предыдущем шаге (previousState), то таймер останавливается (см. второе предложение). Если не совпадает, то формируется список Neighbors клеток, соседних с живыми клетками. Каждая клетка попадает в него столько раз, сколько живых соседей она имеет. Далее с помощью предиката decompose/2 полученный список разбивается на группы — преобразуется в список Groups элементов вида tuple(Cell, [Cell, Cell, Cell]). Затем с помощью предиката map/2 списки одинаковых элементов заменяются их количеством, и получается список NList элементов вида tuple(Cell, N), которые содержат указатель на объект клетки и количество граничащих с ней живых клеток. Для элементов этого списка применяются правила Конвея.

Если живая клетка не граничит с другими живыми клетками, то она в список Neighbors не попадает. Такие клетки образуют список RestCells. В соответствии с правилами Конвея все они становятся пустыми.

Добавим для окна gameDialog обработчики событий нажатия на кнопки ">" ("шаг"), "Старт" и "Очистить", а также обработчик событий TimerListener.

При нажатии на кнопку ">" выполняется один шаг итерации.

clauses
    onStepClick(_Source) = button::defaultAction:-
        gameControl_ctl:step().
Листинг 8.15. Определение предиката onStepClick

При нажатии на кнопку "Очистить" поле очищается и снимается флажок из поля "Тор".

clauses
    onClearClick(_Source) = button::defaultAction:-
        gameControl_ctl:clear(),
        torus_ctl:setChecked(false).
Листинг 8.16. Определение предиката onClearClick

Запуск и остановка таймера

Таймер запускается при нажатии на кнопку "Старт". Надпись на этой кнопке при этом меняется на надпись "Стоп". При повторном нажатии на кнопку таймер останавливается, возвращается прежняя надпись.

clauses
    onStartClick(_Source) = button::defaultAction:-
        "Старт" = start_ctl:getText(),
        !,
        timer := timerSet(100),
        start_ctl:setText("Стоп").
    onStartClick(_Source) = button::defaultAction:-
        stop().
Листинг 8.17. Определение предиката onStartClick

Шаг итерации выполняется на каждый тик таймера.

clauses
    onTimer(_Source, _Timer):-
        gameControl_ctl:step().
Листинг 8.18. Определение предиката onTimer

Объявим в интерфейсе gameDialog предикаты isTorus и stop. Предикат isTorus возвращает состояние флажка (true или false). Предикат stop останавливает таймер.

predicates
    isTorus: () -> boolean.
    stop: ().
Листинг 8.19. Объявление предикатов в интерфейсе gameDialog

Остается объявить факт-переменную timer и определить предикаты isTorus и stop в имплементации класса gameDialog.

facts
    timer : timerHandle := erroneous.

clauses
    isTorus() = torus_ctl:getChecked().

    stop():-
        isErroneous(timer),
        !.
    stop():-
        timerKill(timer),
        timer := erroneous,
        start_ctl:setText("Старт").
Листинг 8.20. Определение в имплементации класса gameDialog

Флажок в поле "Тор" можно ставить или снимать в любой момент времени. Это сразу повлияет на поведение конфигурации живых клеток.

На рис. 8.3 рис. 8.3 показаны конфигурации живых клеток, которые сходятся через несколько шагов к известным пульсарам — фигурам, которые периодически воспроизводят самих себя. Эти фигуры были показаны на рис. 8.2 рис. 8.2 слева (кембриджский пульсар с периодом 3) и справа вверху (пульсар с периодом 15). Исходные конфигурации нельзя получить одну из другой.

Фигуры (a) и (b) сходятся к пульсару, показанному слева на рис. 8.2; фигуры (c) и (d) сходятся к пульсару, показанному на рис. 8.2 справа вверху

Рис. 8.3. Фигуры (a) и (b) сходятся к пульсару, показанному слева на рис. 8.2; фигуры (c) и (d) сходятся к пульсару, показанному на рис. 8.2 справа вверху

Упражнения

8.1. Определите предикат, который по заданной конфигурации живых клеток находит возможные конфигурации живых клеток (не содержащие изолированных клеток), из которых она могла появиться за один шаг.

8.2. Определите операцию вычисления периода, через который конфигурация живых клеток повторяется, и отображение его в строке заголовка окна.

8.3. Определите операции сохранения поколений и прокручивания поколений в обратном порядке.

8.4. Реализуйте игру "Жизнь" на поле, составленном из правильных шестиугольников.

8.5. Добавьте кнопку и числовое поле для задания случайной начальной конфигурации живых клеток. В поле вводится число живых клеток. По нажатию на кнопку живые клетки случайным образом расставляются на поле.

< Лекция 7 || Лекция 8 || Лекция 9 >
Алексей Роднин
Алексей Роднин
Россия
Роман Гаранин
Роман Гаранин
Беларусь, Брест