Глава     6

Учебный пример: игра "Бильярд"


Разделы

Содержание

Во втором примере мы построим простую имитацию бильярдного стола. Программа написана на языке Object Pascal для Macintosh 5 . Как и в случае с восемью ферзями, разработка делает упор на создание автономных агентов, взаимодействующих между собой для достижения желаемого результата.

Комментарий
5 Программа "Бильярд" была разработана на компьютере PowerPC Macintosh с использованием компилятора CodeWarrior Pascal версии 1.1. В версии для PowerPC движение столь быстро, что я решил вставить в процедуру Ball.update замедляющий цикл, несколько раз вызывающий метод draw. Игра, реализованная программой из этой главы, не соответствует никакой настоящей игре. Это не пул, это не бильярд, это просто движение шаров по столу со стенками и лузами.

6.1. Элементы бильярда

Разделы

Для пользователя бильярдный стол представляет собой окно, содержащее прямоугольник с лузами по углам,═15 черных шаров и═1 белый шар. Нажатием кнопки мыши пользователь имитирует удар кием по шару, сообщая ему некоторую энергию. Шар движется в сторону, противоположную указателю мыши. Получив энергию, шар начинает катиться, отскакивая от стенок, ударяя другие шары, и, наконец, попадает в лузу. Когда один шар сталкивается с другим, часть энергии первого передается второму и в результате направление движения обоих шаров меняется.

6.2. Графические объекты

Разделы

Основу имитации составляют три списка графических объектов, представляющих стенки, лузы и шары. Каждый графический объект включает в себя поле ссылки и поле, описывающее местоположение объекта на экране 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, проверяющая, ударился ли движущийся шар обо что-нибудь, также упрощается аналогичным образом. Это можно увидеть в полном исходном тексте в Приложении Б.

Упражнения

Разделы

  1. Предположим, вы хотите производить определенное действие каждый раз, когда программа ╚Бильярд╩ выполняет цикл обработки события. В каком месте лучше всего поместить этот код?
  2. Предположим, вы хотите сделать шары цветными. Какие части программы вам придется изменить?
  3. Предположим, вы хотите добавить лузы на боковых стенках, как на обычном бильярдном столе. Какие части программы вам придется изменить?
  4. Программа ╚Бильярд╩ использует метод, при котором в цикле просматривается список шаров и каждый шар, имеющий энергию, немного сдвигается. Альтернативный и более объектно-ориентированный подход заключается в том, чтобы позволить каждому шару, пока он имеет энергию, изменять свое состояние и состояние шаров, которые он задевает. Тогда для запуска модели бильярда необходимо только придать движение белому шару. Измените программу, чтобы использовать этот подход. Что дает более реальную модель? Почему?

Учебный пример: игра "Бильярд"