Лабораторная работа № 12 Программирование конечных автоматов Цель работы: ознакомиться с некоторыми аспектами теории конечных автоматов и их применение в программировании; запрограммировать пример конечного автомата. Задание: разработать многомодульное приложение, симулирующее поведение рыбки в водоёме, используя автоматный стиль программирования. Теоретические сведения Конечный автомат (КА) представляет собой абстрактную модель некоторого устройства, имеющего один вход и один выход, которое в каждый момент времени может находиться в одном из нескольких возможных состояний. В программированииэтаконцепция оказаласьоченьудобнойдляструктурирования разрабатываемых программ. Грамотное применение конечных автоматов делает более понятной логику программы, облегчает ее написание, отладку и сопровождение. Стиль программирования, основанный на использовании КА, получил название автоматное программирование. Формально автомат можно представить в виде пятерки: A=(V, Q, q0, F, δ), где V – входной алфавит, представляющий собой конечное множество символов, из которых формируются входные цепочки, принимаемые конечным автоматом; Q – множество внутренних состояний автомата; q0 – некоторое начальное состояние автомата, выбранное из множества его внутренних состояний ( q0 Q ); F – множество конечных (заключительных) состояний ( F Q ); δ – функция переходов, которая по значению текущего состояния КА и входному символу (или пустой цепочке) определяет множество всех состояний, в которые возможен переход. Принято полагать, что КА начинает работу в состоянии q0, последовательно считывая по одному символу входной строки. Считанный символ переводит автомат в новое состояние в соответствии с функцией переходов. Классическими примерами автоматов могут считаться, к примеру, машина Тьюринга или автомат Маркова. 1 Для описания конечных автоматов можно также использовать диаграмму состояний и таблицу переходов. Диаграмма состояний представляет ориентированный граф вершины которого соответствуют состояниям автомата, а дуги – переходам из одного состояния в другое. Метки дуг – это символы, по которым осуществляется переход из одного состояния в другое. Если переход из состояния q 1в q 2 может быть осуществлен по одному из нескольких символов, то все они должны быть надписаны над дугой диаграммы. Таблица переходов является табличным представлением функции переходов. Обычно в такой таблице каждой строке соответствует отдельное состояние, а каждому столбцу – один допустимый входной символ. В ячейке на пересечении строки и столбца записывается состояние, в которое должен перейти КА, если в данном состоянии он считал данный входной символ. Для изучения автоматного стиля программирования разработаем несложное приложение модного в наше время жанра – симулятора, а именно, симулятор поведения рыбки в водоеме. Естественно, это будет крайне упрощенная модель. Задачей рыбки будет сбор всей пищи в водоёме. В любой момент времени она может выполнять одно из четырех действий: хаотично двигаться по водоему в поисках пищи; целенаправленно двигаться к обнаруженной пище; возвращаться домой после того, как пища съедена; отдыхать дома. Неформальное описание алгоритма работы КА имеет следующий вид: 1. в начальном состоянии рыбка находится в поисках пищи, т.е. двигается в случайном направлении до тех пор, пока не увидит пищу; 2. когда пища обнаружена, рыбка плывёт к ней, «съедает» и возвращается домой; 3. приложение завершает работу в случае, если рыбка находится дома, и ею собрана вся имеющаяся в водоёме пища. Исходя их этого, КА будет иметь четыре состояния (множество Q): старт – начальное состояние q0; поиск; движение к пище; дома – конечное состояние F, если водоем пуст. Диаграмма состояний данного КА показана на рисунке 12.1. 2 Рисунок 12.1. Диаграмма состояний КА Таблица переходов, управляющая моделью поведения рыбки, приведена в таблице 12.1. Таблица 12.1. Таблица переходов автомата «Рыбка» Входной Начало символ работы Увидела пищу Рядом с пищей Водоём не пуст Водоём пуст Состояние Старт Поиск Движение к пище Поиск Движение к пище Движение домой Дома Поиск Конец работы программы Практическая часть Подготовка к выполнению работы Создаем новый проект и изменяем у формы свойство Caption на название лабораторной работы. Сохраняем проект в рабочую папку командой File → Save Project as. Формирование интерфейса программы Создадим интерфейс программы, симулирующей водоём. В данном проекте основными компонентами являются Image и Timer. Первый компонент будет использоваться для отображения текущего статуса объектов, а второй – для регулярного обновления статуса. Примерный вид интерфейса программы показан на рисунке 12.2. 3 В качестве фона будем использовать изображение некоторого реального водоёма. Подберите его самостоятельно. Загрузку изображения можно выполнить одним из двух способов: изменением свойства Picture объекта Image1; использованием команды Image1.Picture.LoadFromFile('путь к изображению') в начале работы программы, например, в обработчике события OnCreate формы. Кнопки «Инициализация» объектов (ButtonIni) и «Запуск» (ButtonGo) разместим на компоненте Panel, которую «прижмем» к нижней границе формы (Align = alBottom). Компонент Image1 «растянем» на всю свободную площадь формы (Align = alClient) и изменим у него свойство Stretch на значение true. Это необходимо для автоматического масштабирования выбранного изображения по размерам компонента Image1. У компонента Timer1 свойство Enabled должно иметь значение false. Рисунок 12.2. Интерфейс программы Структура программы Для определения структуры программы определим классы, которые потребуются в процессе работы, их поля и методы. Первый класс – TFish («рыбка»). Его поля: 4 координаты; размер; скорость движения; радиус видимости – расстояние, на котором рыбка может заметить пищу; количество собранной пищи. Методы класса TFish: создание; вычисление расстояния; хаотичное движение; движение к пище; движение домой. Второй класс – TFood («Пища»). Его поля: координаты; размер. Метод у данного класса единственный – создание экземпляра класса. Заметим, что у «Рыбки» и «Пищи» есть общие свойства (координаты и размер) и общий метод – создание экземпляра по заданным координатам. По этой причине логично ввести общий класс «Игровой объект» и, воспользовавшись принципом наследования, определить поля и методы у классов-потомков «Рыбка» и «Пища». Поскольку требуется разработать многомодульное приложение, для каждого класса создадим свой модуль (команда File→New→Unit-Delphi): unit GameObjects – описание класса «Игровой объект»; unit StaticObjects – описание классов для статичных объектов (в проектируемом варианте программы такой объект один – «Пища»); unit LiveObjects – описание классов для «живых»объектов (в проектируемом варианте программы такой объект один – «Рыбка»). Модуль GameObjects Создаем новый модуль GameObjects и описываем в нем два класса: игровой объект – TGameObject; список игровых объектов – TGameObjectsList, который будет наследником класса Tlist. Как было сказано выше, класс TGameObject имеет три поля: координаты на плоскости и размер. Будем изображать игровые объекты окружностями, поэтому в качестве размера объекта зададим его радиус. Единственным методом класса будет конструктор Create. Таким образом, описание класса TGameObject будет иметь вид: TGameObject = class 5 X: integer; Y: integer; Rad: integer; Constructor create(objX, objY, objRad: integer); end; Реализация конструктора заключается в присвоении переданных параметров соответствующим полям класса: Constructor TGameObject.Create(objX, objY, objRad: integer); begin X := objX; Y := objY; Rad := objRad; end; Класс TGameObjectsList содержит только два метода: конструктор Create и процедуру удаления элемента из списка – Delete: TGameObjectsList = class(TList) Constructor Create; procedure Delete(N: Integer); end; Конструктор данного класса полностью наследуется от конструктора класса TList, поэтому в его коде между операторными скобками достаточно указать только ключевое слово inherited и название наследуемого метода: Constructor TGameObjectsList.create; begin inherited Create; end; Ключевое слово inherited используется в том случае, когда требуется обратиться не к методу самого класса, а к методу его родительского класса. Если название методов совпадает (как в данном примере), то его можно не указывать. Самостоятельно напишите реализацию метода Delete, учитывая, что соответствующий метод класса TList удаляет из списка только ссылку на объект. Удаление самого объекта из памяти необходимо выполнить до вызова метода Delete родительского класса. 6 Модуль StaticObjects Создаем новый модуль GameObjects и описываем в нем класс TFood: TFood = class(TGameObject) Constructor create(x, y, rad: integer); end; Данный модуль является самым простым, поскольку все методы и свойства описываемого в нём класса наследуются от родительского класса TGameObject. В его интерфейсную часть (после ключевого слова interface) добавляем секцию Uses, указав в ней имя подключаемого модуля GameObjects. Конструктор Create – это просто вызов конструктора родительского класса: Constructor TFood.Create(x, y, rad: integer); begin inherited Create(x, y, rad); end; Модуль LiveObjects Создаем новый модуль LiveObjects, добавив в интерфейсную часть строку Uses GameObjects, StaticObjects. Этот модуль является ключевым, поскольку в нём будет описана практически вся механика проекта. Присвоим названия всем возможным состояниям конечного автомата: поиск пищи – FishSearchFood; движение к пище – GoFood; возвращение домой – GoHome; нахождение дома – InHouse; конец игры – EndOfGame. Опишем соответствующий перечисляемый тип: TFishState=(FishSearchFood, GoFood, GoHome, InHouse, EndOfGame). Класс TFish наследует все свойства от класса TGameObject и, кроме того, имеет свои дополнительные поля и методы, которые отличают рыбку от других игровых объектов. Поля класса: VisRad: integer – радиус видимости; Speed: integer – скорость движения; State :TFishState – текущее состояние; EatenFood: integer – количество поглощенной пищи. 7 В списке полей класса TFish не указаны координаты и радиус объекта. Этого и не требуется, поскольку данные поля будут унаследованы от родительского класса TGameObject. Методы класса: Constructor Create(fishX, fishY, fishRad, fishVisRad, fishSpeed: integer); Function DistanceToPoint(Xx, Yy: integer): real– функция вычисления расстояния от рыбки до произвольной точки с координатами (Xx,Yy); Function IsFoodNearby(ObjectsList:TGameObjectsList; var NumF:integer): boolean – булева функция, определяющая, есть ли пища в поле видимости рыбки; procedure MoveToFood(ObjectsList:TGameObjectsList; var NumF:integer) процедура движения к пище; procedure SearchFood(wdth,hght:integer; var TargetX,TargetY:integer; ObjectsList: TGameObjectsList; var NumF: Integer) – процедура поиска пищи; procedure MoveToHouse – процедура движения домой. Рассмотрим реализацию методов. Конструктор будет иметь вид: Constructor TFish.Create(fishX, fishY, fishRad, fishVisRad, fishSpeed: integer); begin inherited Create(fishX, fishY, fishRad); VisRad:=fishVisRad; Speed:=fishSpeed; State:=FishSearchFood; EatenFood:=0; end; При вызове конструктора родительского класса заполняются поля координат и радиуса, остальные поля заполняются в самом методе. Самостоятельно напишите реализацию метода DistanceToPoint, вычисляющего расстояние от текущего положения рыбки до точки, задаваемой параметрами процедуры. Проанализируем функцию, определяющую наличие еды рядом с рыбкой. FunctionTFish.IsFoodNearby(ObjectsList: TGameObjectsList; var NumF: Integer):boolean; var i: Integer; begin result:=false; for i := 0 to ObjectsList.Count - 1 do if TObject(ObjectsList.Items[i]) is TFood then 8 if DistanceToPoint(TFood(ObjectsList.Items[i]).X, TFood(ObjectsList.Items[i]).Y) < VisRad then begin result:= true; NumF:= i; State:= MoveToFood; Break; end; end; Важное место в данном методе отводится функции определения типа объекта, расположенного по адресу, на который указывает элемент списка объектов. Условие if TObject(ObjectsList.Items[i]) is TFood служит для определения, является ли объект, расположенный по указанному адресу (на позиции с номером i) в списке объектов, пищей, т.е. объектом типа TFood, или нет. Оператор is возвращает значение true, если указанный слева от него объект относится к классу, указанному справа, или к его потомкам. Таким образом, выражение типа R is TС является логическим выражением. При получении значение true в первом условном операторе производится проверка условия: располагается ли данный объект в зоне видимости рыбки, и если он там располагается, то выполняются следующие действия: возвращается признак того, что пища обнаружена (result:= true); позиция объекта в списке возвращается в вызывающую процедуру (NumF := i); состояние рыбки изменяется с поиска еды на движение к ней (State := GoFood); поиск пищи прекращается (Break). Алгоритм поиска пищи строится по следующей схеме: 1. Вычисляется расстояние от текущего положения рыбки до точки с координатами targetX, targetY, которые передаются в качестве входных параметров и представляют собой случайно выбранные числа из диапазона от 0 до ширины или высоты компонента Image1. 2. Вычисляются координаты направляющего вектора движения к этой точке. 3. Производится пересчет координат объекта-рыбки. 4. Если в поле зрения рыбки находится пища, то рыбка начинает двигаться к ней, т.е. ее состояние изменяется на значение GoFood (движение к пище). В противном случае, если рыбка добралась до точки (расстояние между ней и точкой меньше радиуса), происходит выбор нового направления движения, 9 а если нет, она продолжает движение к заданной цели. Новое направление движения определяется генерацией случайных чисел в диапазоне от 0 до величины ширины (координата X) или высоты (координата Y) модели водоема. Реализация метода выглядит следующим образом: procedure TFish.SearchFood(wdth, hght: integer; var TargetX, TargetY: integer; ObjectsList: TGameObjectsList; var NumF: integer); var vectorX, vectorY:integer; s:real; begin S:= DistanceToPoint(targetX, targetY); vectorX:= targetX - X; vectorY:= targetY - Y; X:= X + Speed * Round(vectorX / s); Y:= Y + Speed * Round(vectorY / s); if IsFoodNearby(ObjectsList, NumF) then state:=GoFood else if DistanceToPoint(TargetX, TargetY) < Rad then begin TargetX:= Random(wdth); TargetY:= Random(hght); end; end; Процедура, описывающая движение рыбки к пище, имеет схожую структуру. Цель – это объект класса «Пища» под номером NumF в списке объектов. Условие окончания движения к пище и изменения режима на GoHome (возвращение домой) заключается в том, что расстояние до объекта меньше, чем радиус «Рыбки». Если это условие выполняется, то необходимо сделать следующие операции: изменить состояние автомата на «Движение домой»; увеличить количество «съеденной» пищи на единицу; удалить из списка объектов «съеденный» объект, т.е. элемент с номером NumF. Если же оно не выполняется, то следует выполнить перерасчет координат в направлении движения. procedure TFish.MoveToFood(ObjectsList: TGameObjectsList; var NumF: Integer); var targetX,targetY: integer; vectorX,vectorY: integer; s:real; 10 begin targetX:= Tfood(ObjectsList.Items[NumF]).X; targetY:= Tfood(ObjectsList.Items[NumF]).Y; S:= DistanceToPoint(targetX, targetY); if S < Rad then begin ObjectsList.delete(NumF); inc(EatenFood); state:=GoHome; end else begin vectorX:= targetX - X; vectorY:=targetY - Y; X:= X + Round(Speed * (vectorX / s)); Y:= Y + Round(Speed * (vectorY / s)); end; end; Самостоятельно напишите реализацию метода MoveToHouse – движение рыбки домой. Домом будем считать левый верхний угол компонента Image1 – точку с координатами (0, 0). Модуль Unit1 После того, как написаны коды всех отдельных компонентов разрабатываемой программы, необходимо создать управляющую процедуру, которая будет обращаться к ним по мере необходимости, а также процедуры инициализации отдельных элементов, отображения текущего состояния водоема и его обновления. Рыбку и пищу будем изображать кругами, закрашенными в разные цвета, а область видимости – окружностью. Опишем следующие константы: NumOfFood = 5 – количество пищи (объектов класса TFood) в водоеме; FishRad = 5 – радиус объекта «Рыбка»; FishVis =100 – область видимости рыбки; FishSpeed = 5 – скорость движения рыбки; FoodRad = 4 – радиус объекта пища; Введем глобальные переменные: NumF: integer – номер обнаруженного объекта пища; 11 List: TGameObjectsList – список всех объектов водоема; targetX, targetY: integer – координаты точки, к которой в данный момент движется рыбка. Начнёмсобработчикасобытия OnCreate формы. Внемнеобходимоопределить начальное направление движения рыбки. Оно может быть произвольным, например, в направлении правого нижнего угла компонента Image1. Помимо этого, до инициализации объектов водоема надо запретить нажатие кнопки «Запуск». procedure TForm1.FormCreate(Sender: TObject); begin targetX := Image1.Width; targetY := Image1.Height; ButtonGo.Enabled := false; Randomize; end; Опишем процедуру рисования объектов водоема и сделаем ее методом класса Form1, для чего добавим ее заголовок в раздел private. Сам процесс рисования разобьем на два шага: формирование виртуального изображения типа TBitmap; загрузка изображения в свойство Picture компонента Image1. procedure TForm1.Draw(wdth,hght:integer; list:TGameObjectsList); var i: Integer; Fish: Tfish; Food: TFood; bmp: TBitmap; begin <формирование виртуального изображения> image1.Picture.Assign(bmp); bmp.Free; end; Рассмотрим последовательные шаги формирования изображения. Вначале создаем объект типа TBitmap и задаем его размеры равными размера компонента Image1: bmp := TBitmap.Create; bmp.Width:= Image1.Width; bmp.Height:= Image1.Height; Далее в цикле просматриваем все объекты, определяя тип каждого из них. В зависимости от этого рисуем либо рыбку, либо пищу: for i := 0 to List.Count - 1 do if TObject(list.Items[i]) is TFish then 12 begin <рисуем рыбку> end else begin <рисуем пищу> end; Рисование рыбки выполняется следующим образом: Fish := TFish(list.Items[i]); bmp.Canvas.Pen.Color := clBlue; bmp.Canvas.Brush.Color := clBlue; bmp.canvas.Ellipse(Fish.X - Fish.rad, Fish.Y - Fish.rad, Fish.X + Fish.rad, Fish.Y + Fish.rad); bmp.Canvas.Brush.Style := bsClear; bmp.Canvas.Pen.Color := clRed; bmp.Canvas.Ellipse(Fish.X - Fish.VisRad, Fish.Y - Fish.VisRad, Fish.X + Fish.VisRad, Fish.Y + Fish.VisRad); Аналогичным образом рисуем пищу: Food := TFood(list.Items[i]); bmp.Canvas.Pen.Color := clGreen; bmp.Canvas.Brush.Color := clGreen; bmp.canvas.Ellipse(Food.X - Food.rad, Food.Y - Food.rad, Food.X + Food.rad,Food.Y + Food.rad); Создаем обработчик события OnClick кнопки инициализации объектов: procedure TForm1.ButtonIniClick(Sender: TObject); var i, x, y: integer; fish: Tfish; food: Tfood; begin <тело процедуры> end; Вначале «на всякий случай» останавливаем таймер, поскольку кнопку инициализации можно нажать и в процессе симуляции: Timer1.Enabled := false; Создаем список объектов водоема: List := TGameObjectsList.Create; 13 Устанавливаем базовые координаты рыбки: x := RandomRange(FishRad, Image1.Width - FishRad); y:= RandomRange(FishRad, Image1.Height - FishRad); Функция RandomRange генерирует случайное целое число в пределах указанного диапазона. Она более удобна, чем функция Random. Для ее использования в строку Uses необходимо добавить модуль Math. Создаем объект класса «Рыбка» и добавляем ее в список: fish := TFish.Create(x, y, FishRad, FishVis, FishSpeed); List.Add(fish); Приступаем к созданию объектов «Пища». Соответствующая последовательность действий ничем не отличается от той, которая применялась при создании объекта «Рыбка». Для того чтобы не размещать пищу совсем близко от жилища рыбки, первым параметром в процедуре RandomRange укажем радиус видимости рыбки: for i := 1 to NumOfFood do begin x := RandomRange(FishVis, Image1.Width - FoodRad); y := RandomRange(FishVis, Image1.Height - FoodRad); food := TFood.Create(x, y, FoodRad); List.Add(Food); end; В конце процедуры рисуем все объекты и делаем доступной кнопку «Запуск»: Draw(Image1.Width, Image1.Height, List); ButtonGo.Enabled:= true; Самостоятельно напишите обработчик события OnClick кнопки «Запуск», активировав в нем таймер. Последней принципиально значимой процедурой является обновление состояния объектов на игровом пространстве. Это и есть собственно конечный автомат – своего рода управляющий блок в программе. Создаем обработчик события OnTimer компонента Timer1: procedure TForm1.Timer1Timer(Sender: TObject); var i: Integer; fish:Tfish; begin <управляющий блок> end; 14 Структура управляющего блока соответствует диаграмме состояний КА, показанной на рисунке 13.1. В списке объектов находим рыбку и в зависимости от ее состояния вызываем те или иные методы класса TFish. Для просмотра списка объектов будем использовать цикл с предусловием: i := 0; while i <= List.Count-1 do begin <команды конечного автомата> inc(i); end; Как правило, для просмотра всех элементов списка используется цикл for i:=0 to Count - 1 do. Однако в данном случае его применение может привести к возникновению аварийных ситуаций. Попробуйте объяснить причину. Код конечного автомата имеет следующий вид (см. рисунок 12.1. или таблицу 12.1): if TObject(List.Items[i]) is TFish then begin fish := TFish(List.Items[i]); case fish.State of FishSearchFood: fish.SearchFood(Image1.Width, Image1.Height, targetX, targetY, List, NumF); GoFood: fish.MoveToFood(list,NumF); GoHome: fish.MoveToHouse; InHouse: if fish.EatenFood = NumOfFood then Fish.State := EndOfGame else Fish.state := FishSearchFood; EndOfGame: timer1.Enabled:=false; end; end; По окончанию цикла обращаемся к процедуре рисования всех объектов: Draw(Image1.Width, Image1.Height, List); Запускаем программу и проверяем ее работоспособность. 15 Поэкспериментируем с различными значениями констант и свойством Interval у таймера. В процессе симуляция поиска рыбкой пищи изображение может периодически «моргать». Для коррекции данного дефекта попробуйте изменить свойство DoubleBuffered у формы на значение true. Создайте в правой части формы панель управления и разместите на ней дополнительные компоненты, позволяющие регулировать параметры симуляции: начальное положение рыбки, радиус обзора, скорость движения, скорость обновления изображения и количество пищи. Реализуйтедляниханализкорректностиданныхииспользуйт е вводимые значения при инициализации изображения. Добавьте на изображении домик рыбки. Добавьте на панель управления компонент типа TMemo, в котором отображаласьбыисториясостоянийрыбкииколичествооста вшейся пищи на данный момент. Варианты заданий для самостоятельного выполнения Значения параметров, о которых явно не сказано в задании, следует задавать в виде констант. 12 Добавьте автоматическую имитацию смены времени суток: утро, день, вечер, ночь. Каждое время характеризуется своим изображением водоёма. Рыбка с наступлением вечера перемещается к дому, где спит всю ночь. Утром она вновь начинает поиск пищи. 16