Глава     6
Учебный пример: игра "Бильярд"
Разделы
- Вход 6.1 Элементы бильярда
- Вход 6.2 Графические объекты
- Вход 6.3 Основная программа
- Вход 6.4 Использование наследования
- Вход Упражнения
Содержание
Во втором примере мы построим простую имитацию бильярдного стола. Программа написана на языке Object Pascal для Macintosh 5 . Как и в случае с восемью ферзями, разработка делает упор на создание автономных агентов, взаимодействующих между собой для достижения желаемого результата.
Комментарий
| 5 Программа "Бильярд" была разработана на компьютере PowerPC Macintosh с использованием компилятора CodeWarrior Pascal версии 1.1. В версии для PowerPC движение столь быстро, что я решил вставить в процедуру Ball.update замедляющий цикл, несколько раз вызывающий метод draw. Игра, реализованная программой из этой главы, не соответствует никакой настоящей игре. Это не пул, это не бильярд, это просто движение шаров по столу со стенками и лузами.
| |
6.1. Элементы бильярда
Разделы
Для пользователя бильярдный стол представляет собой окно, содержащее прямоугольник с лузами по углам,═15 черных шаров и═1 белый шар. Нажатием кнопки мыши пользователь имитирует удар кием по шару, сообщая ему некоторую энергию. Шар движется в сторону, противоположную указателю мыши. Получив энергию, шар начинает катиться, отскакивая от стенок, ударяя другие шары, и, наконец, попадает в лузу. Когда один шар сталкивается с другим, часть энергии первого передается второму и в результате направление движения обоих шаров меняется.
6.2. Графические объекты
- Вход 6.2.1 Графический объект Wall (стенка)
- Вход 6.2.2 Графический объект Hole (луза)
- Вход 6.2.3 Графический объект Ball (шар)
Разделы
Основу имитации составляют три списка графических объектов, представляющих стенки, лузы и шары. Каждый графический объект включает в себя поле ссылки и поле, описывающее местоположение объекта на экране 6 .
Комментарий
| 6 Неясно, куда поместить изучение этого примера. С одной стороны, читателю важно как можно быстрее увидеть применение объектных принципов; поэтому желательно, чтобы этот пример встретился в книге пораньше. С другой стороны, эта программа только выиграла бы от более изощренной техники, которая обсуждается ниже. В частности, графические объекты было бы лучше представлять в виде иерархии наследования, как описано в главе 7. Кроме того, считается плохим стилем программирования размещение ссылочных полей в области данных объектов, объединенных в список; лучше отделить контейнер и элементы списка. Решение этих проблем нетривиально и содержит сложности. Мы обсудим классы контейнеров в главе 15.
| |
Мы ввели упрощающее предположение, что все графические объекты занимают прямоугольную область. Это, конечно, совершенно неверно для круглых объектов наподобие шара. Более реалистичной альтернативой было бы написать процедуру, определяющую, пересеклись ли два шара, на основе их действительной геометрии. Но сложность процедуры только отвлекла бы нас от того главного, чего мы хотим добиться, приводя этот пример, а именно понять способ наделения объектов ответственностью за их поведение. Каждый графический объект знает не только как изображать себя, но и как взаимодействовать с другими объектами нашей модели мира.
6.2.1. Графический объект Wall (стенка)
Графические объекты
Первым из наших трех графических объектов является стенка Wall. Она определяется следующим описанием класса:
Wall = object
(* поля данных *)
link : Wall;
region : Rect;
(* угол отскока шаров *)
convertFactor : real;
(* инициализирующая функция *)
procedure initialize
(left, top, right, bottom : integer; cf : real);
(* изображение стенки *)
procedure draw;
(* сообщение стенке, что о нее ударился шар *)
procedure hitBy (aBall : Ball);
end;
|
Поле link (ссылка) служит для поддержания списка объектов Wall. Инициализирующий метод просто задает местоположение (region) стенки и параметр отскока (convert factor):
procedure Wall.initialize
(left, top, right, bottom : integer; cf : real);
begin
(* инициализация convertFactor *)
convertFactor := cf;
(* установить область для стены *)
SetRect (region, left, top, right, bottom);
end;
|
Стенка может быть нарисована просто как сплошной прямоугольник. Это выполняется стандартной процедурой для Macintosh:
procedure Wall.draw;
begin
PaintRect (region);
end;
|
Самое интересное происходит со стенкой, когда о нее ударяется шар. Направление его движения изменяется, основываясь на значении параметра convertFactor для стенки. (Переменная convertFactor равна или нулю, или pi, в зависимости от того, горизонтальная стенка или вертикальная.) В результате столкновения шар будет двигаться в новом направлении.
procedure Wall.hitBy (aBall : Ball);
begin
(* оттолкнем шар от стенки *)
aBall.setDirection(convertFactor═≈ aBall.direction);
end;
|
6.2.2. Графический объект Hole (луза)
Графические объекты
Hole (луза) определяется следующим описанием класса:
Hole = object
(* поля данных *)
link : Hole;
region : Rect;
(* инициализирующая функция *)
procedure initialize (x, y : integer);
(* изображение лунки *)
procedure draw;
(* сообщение лузе, что в нее попал шар *)
procedure hitBy (aBall : Ball);
end;
|
Как и в случае стенок, инициализация и изображение лузы в основном состоят из вызова соответствующих стандартных подпрограмм:
procedure Hole.initialize (x, y : integer);
begin
(* определить область с центром в x, y *)
SetRect(region, x-5, y-5, x+5, y+5);
end;
procedure Hole.draw;
begin
PaintOval (region);
end;
|
Больший интерес представляет происходящее при ╚ударе╩ шара о лузу. Есть два случая. Если шар оказался белым (он идентифицируется глобальной переменной CueBall), то он возвращается назад в игру на определенную позицию. В остальных случаях шар лишается энергии и убирается со стола в специальную область.
procedure Hole.hitBy (aBall : Ball);
begin
(* остановить шар; убрать его со стола *)
aBall.energy :=═0.0;
aBall.erase;
(* передвинуть шар *)
if aBall = CueBall then
aBall.setCenter(50.100);
else begin
saveRack := saveRack +═1;
aBall.setCenter (10 + saveRack *═15,═250);
end;
(* перерисовать шар *)
aBall.draw;
end;
|
6.2.3. Графический объект Ball (шар)
Графические объекты
Последним графическим объектом является шар, определяемый следующим описанием класса:
Ball = object
(* поля данных для шара *)
link : Ball;
region : Rect;
direction : real; (* направление в радианах *)
energy : real;
(* инициализирующая функция *)
procedure initialize (x, y : integer);
(* методы *)
procedure draw;
procedure erase;
procedure update;
procedure hitBy (aBall : Ball);
procedure setDirection (newDirection : real);
(* возвращают x, y═≈ координаты центра шара *)
function x : integer;
function y : integer;
end;
|
В дополнение к полям ссылки (link) и местоположения (region), общими с остальными объектами, шар имеет два новых поля данных: direction (направление), вычисленное в радианах, и energy (энергия), представляющее собой вещественное значение. Как и в случае лузы, шар инициализируется аргументами, описывающими координаты его центра. Первоначально шар не имеет энергии и его направление нулевое.
procedure Ball.initialize (x, y : integer);
begin
SetRect (region, x-5, y-5, x+5, y+5);
setDirection (0.0);
energy :=═0.0;
end;
|
Шар изображается либо окружностью, либо сплошным кругом, в зависимости от того, является ли он белым или нет.
procedure Ball.draw;
begin
if self = CueBall then
(* рисуем окружность *)
FrameOval (region);
else
(* рисуем круг *)
PaintOval (region);
end;
procedure Ball.erase;
begin
EraseRect (region);
end;
|
Метод update используется для изменения позиции шара. Если он имеет заметную энергию, то слегка сдвигается, а затем проверяет, не задел ли он другой объект. Глобальная переменная ballMoved устанавливается в true, если какой-либо шар на столе сдвинулся. Если шар задел другой объект, шар сообщает об этом объекту. Сообщения бывают трех видов; они соответствуют ударам по лузе, стенке и другим шарам. Наследование, которое мы изучаем в главе═7, предоставляет методы объединения этих трех тестов в один цикл.
procedure Ball.update;
var
hptr : Hole;
wptr : Wall;
bptr : Ball;
dx, dy : integer;
theIntersection : Rect;
begin
if energy >═0.5 then
begin
ballMoved := true;
(* удалить шар *)
erase;
(* уменьшить энергию *)
energy := energy═≈═0.05;
(* сдвинуть шар *)
dx := trunc(5.0 * cos(direction));
dy := trunc(5.0 * sin(direction));
offsetRect(region, dx, dy);
(* перерисовать шар *)
draw;
(* проверить, не попали ли в лузу *)
hptr := listOfHoles;
while (hptr <> nil) do
if SectRect (region, hptr.region, theIntersection)
then
begin
hptr.hitBy(self);
hptr := nil;
end
else
hptr := hptr.link;
(* проверить, не ударились ли в стенку *)
wptr := listOfWalls;
while (wptr <> nil) do
if SectRect (region, wptr.region, theIntersection)
then
begin
wptr.hitBy(self);
wptr := nil;
end
else
wptr := wptr.link;
(* проверить, не ударили ли шар *)
bptr := listOfBalls;
while (bptr <> nil) do
if SectRect (region, bptr.region, theIntersection)
then
begin
bptr.hitBy(self);
bptr := nil;
end
else
bptr := bptr.link;
end;
end;
|
Когда один шар ударяет другой, энергия первого делится пополам между ними. Также меняются направления движения обоих шаров.
procedure Ball.hitBy (aBall : Ball);
var
da : real;
begin
(* уменьшить энергию ударяющего шара наполовину *)
aBall.energy := aBall.energy /═2;
(* и добавить ее к нашему шару *)
energy := energy + aBall.energy;
(* установить наше новое направление *)
setDirection(hitAngle(self.x aBall.x,
self.y═≈ aBall.y);
(* и направление ударяющего шара *)
da := aBall.direction═≈ direction;
aBall.setDirection (aBall.direction + da);
end;
function hitAngle (dx, dy : real) : real;
const
PI =═3.14159;
var
na : real;
begin
if (abs(dx) <═0.05) then
na := PI /═2;
else
na := arctan (abs(dy / dx));
if (dx <═0) then
na := PI═≈ na;
if (dy <═0) then
na := -na;
hitAngle := na;
end;
|
6.3. Основная программа
Разделы
В предыдущем параграфе описывались статические характеристики программы. Динамика начинается при нажатии кнопки мыши. При этом вызывается следующая процедура:
procedure mouseButtonDown (x, y : integer);
var
bptr : Ball;
begin
(* присвоим белому шару некоторую энергию *)
CueBall.energy :=═20.0;
(* и направление *)
CueBall.setDirection(hitAngle (CueBall.x═≈ x,
CueBall.y═≈ y));
(* изменения происходят, пока движется хотя бы один шар *)
ballMoved := true;
while ballMoved do
begin
ballMoved := false;
bptr := listOfBalls;
while bptr <> nil do
begin
bptr.update;
bptr := bptr.link;
end;
end;
end;
|
Оставшаяся часть программы относительно прямолинейна и не представлена здесь. Весь текст находится в Приложении Б. Основная часть кода связана с инициализацией новых объектов и организацией цикла ожидания события, то есть действия пользователя.
Главное═≈ понять то, как было децентрализовано управление и как сами объекты были наделены возможностями влиять на ход выполнения программы. Все, что происходит при нажатии кнопки мыши,═≈ это наделение белого шара некоторой энергией. В дальнейшем модель руководствуется исключительно взаимодействием шаров.
6.4. Использование наследования
Разделы
В главе═1 мы описали наследование неформально, а в главе═7 обсудим, как оно работает в каждом из рассматриваемых нами языков. Здесь мы поясним, как наследование используется для упрощения имитации бильярда. Думается, что читателю лучше вернуться к этому параграфу после ознакомления с общими положениями о наследовании в следующей главе.
Первым шагом в использовании наследования в нашей имитации бильярда является описание общего класса ╚графический объект╩. Он породит трех потомков: шары, стенки и лузы. Родительский класс определяется следующим образом:
GraphicalObject = object
(* поля данных *)
link : GraphicalObject;
region : Rect;
(* инициализирующая функция *)
procedure setRegion (left, top, right, bottom :
integer);
(* операции, выполняемые графическими объектами *)
procedure draw;
procedure erase;
procedure update;
function intersect (anObj : GraphicalObject) :
boolean;
procedure hitBy (anObj : GraphicalObject);
end;
|
Инициализирующая функция setRegion просто устанавливает область, занимаемую объектом. Методы draw и update ничего не делают, так как их фактическое поведение определено в дочерних классах. Программа erase очищает область, занимаемую объектом. intersect возвращает значение true, если объект-аргумент пересекается с рассматриваемым объектом. И наконец, метод hitBy также переопределяется в дочерних классах. Хотя двигаются только шары и, следовательно, аргументом этой функции всегда будет шар, тот факт, что класс Ball еще не определен, означает, что мы должны объявить аргумент как имеющий более общий тип GraphicalObject:
procedure GraphicalObject.setRegion
(left, top, right, bottom : integer);
begin
SetRect(region, left, top, right, bottom);
end;
procedure GraphicalObject.draw;
begin
(* переопределяется в дочернем классе *)
end;
procedure GraphicalObject.erase;
begin
EraseRect (region);
end;
procedure GraphicalObject.update;
begin (* переопределяется в дочернем классе *)
end;
procedure GraphicalObject.hitBy(anObject :
GraphicalObject);
begin (* переопределяется в дочернем классе *)
end;
function GraphicalObject.intersect
(anObject : GraphicalObject) : boolean;
var
theIntersection : Rect;
begin
intersect := SectRect
(region, anObject.region, theIntersection);
end;
|
Теперь Ball, Wall и Hole объявляются как подклассы общего класса GraphicalObject, и внутри них ни к чему объявлять данные или функции, если только они не переопределяются:
Hole = object (GraphicalObject)
(* инициализация местоположения лузы *)
procedure initialize (x, y : integer);
(* изображение лузы *)
procedure draw; override;
(* сообщить лузе, что в нее попал шар *)
procedure hitBy (anObject : GraphicalObject);
override;
end;
|
Процедура hitBy должна преобразовать тип аргумента в Ball. Благоразумно проверить тип до приведения:
procedure Wall.hitBy (anObj : GraphicalObject);
var
aBall : Ball;
begin
if Member (anObj, Ball) then
begin
aBall := Ball(anObj);
aBall.setDirection(convertFactor═≈ aBall.direction);
end;
end;
|
Делая класс CueBall подклассом Ball, мы ликвидируем условный оператор в программе изображения шара.
CueBall = Object (Ball)
procedure draw; override;
end;
procedure Ball.draw;
begin
(* рисуем круг *)
PaintOval (region);
end;
procedure CueBall.draw;
begin
(* рисуем окружность *)
FrameOval (region);
end;
|
Наибольшее упрощение достигается тем, что теперь можно держать все графические объекты в одном списке. Программа, рисующая весь экран, записывается так:
procedure drawBoard;
var
gptr : GraphicalObject;
begin
SetPort (theWindow);
gptr := listOfObjects;
while gptr <> nil do begin
gptr.draw;
gptr := gptr.link;
end;
end;
|
Наиболее важным местом этого кода является вызов функции draw внутри цикла. Несмотря на то что вызов написан один, иногда будет вызываться функция класса Ball, а в других случаях═≈ класса Wall или Hole. Тот факт, что одно обращение к функции может привести к вызовам различных функций, относится к понятию полиморфизма. Мы обсудим его в главе═14.
Часть подпрограммы Ball.update, проверяющая, ударился ли движущийся шар обо что-нибудь, также упрощается аналогичным образом. Это можно увидеть в полном исходном тексте в Приложении Б.
Упражнения
Разделы
- Предположим, вы хотите производить определенное действие каждый раз, когда программа ╚Бильярд╩ выполняет цикл обработки события. В каком месте лучше всего поместить этот код?
- Предположим, вы хотите сделать шары цветными. Какие части программы вам придется изменить?
- Предположим, вы хотите добавить лузы на боковых стенках, как на обычном бильярдном столе. Какие части программы вам придется изменить?
- Программа ╚Бильярд╩ использует метод, при котором в цикле просматривается список шаров и каждый шар, имеющий энергию, немного сдвигается. Альтернативный и более объектно-ориентированный подход заключается в том, чтобы позволить каждому шару, пока он имеет энергию, изменять свое состояние и состояние шаров, которые он задевает. Тогда для запуска модели бильярда необходимо только придать движение белому шару. Измените программу, чтобы использовать этот подход. Что дает более реальную модель? Почему?
Учебный пример: игра "Бильярд"