Кузьмицкий И. А. Создаём тетрис в среде BlackBox Component Builder / Kuzmitskiy I. A. Making Tetris in BlackBox Component Builder

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

  1. Игровое поле представляет собой сетку 10х18.
  2. Фигура представляет собой композицию квадратных элементов — ячеек. Классический тетрис (тетрамино) предполагает набор из 7 фигур.
  3. При команде на поворот (нажатие кнопки «вверх»), фигура рисуется повернутой на угол 90º.
  4. Поворот любой фигуры происходит вокруг элемента, расположенного ближе всего к геометрическому центру фигуры.
  5. Падающая фигура с каждым шагом передвигается на одну строку вниз.
  6. Фигура начинает падение с верхней части поля, где располагается вся целиком.
  7. Падение останавливается, когда какой-либо элемент фигуры упирается в нижележащий элемент либо в пол.
  8. Кнопка «вниз» ускоряет падение фигуры. Кнопки «вправо» и «влево» двигают фигуру вправо или влево соответственно, с шагом одна клетка.

Ячейки могут двигаться, а могут и быть неподвижными, поэтому установим, что ячейка может иметь состояние «подвижность». При включенной подвижности, ячейка падает на дно стакана, при выключенном - «висит» на одном месте.

Дальше. У нас есть одновременное передвижение нескольких ячеек, т.е., фигуры. Передвижение прекращается, если хотя бы одна ячейка фигуры наталкивается на неподвижную ячейку.

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

Вводим сущность Фигура, у которой заданы размеры ограничивающего квадрата. Все движения фигуры приводят к перемещениям ячеек внутри неё. Само вращение производится простым перебором фаз. Для разных фигур, количество фаз будет разное. Фигуру мы сделаем не как объект отображения, а как часть модели. Такое решение принято потому, что именно фигуру специально отображать не требуется: фигура отображается всегда в составе модели.

Конструируем "визуальный каркас"

Итак, приступим. Ниже приводятся куски исходного текста. Куски — чтобы не загромождать подробностями, которые и так можно увидеть в прилагаемых исходниках.

Чтобы реализовать задумку, нам придётся проделать следующее:

  1. Создать подходящие структуры для хранения данных сетки, ячеек и прочих сущностей.
  2. Запрограммировать поведение элементов игрового пространства.
  3. Отобразить содержимое игровой сцены на экране.
  4. Осуществить взаимодействие с пользователем (игроком, попросту сказать).

Как выглядит тетрис - знают все! Поэтому начнём сразу с конструирования заготовки отображения сцены и заложим элементы взаимодействия с пользователем. Потом построим модель и заставим её отображаться. Ну а поведение элементов сцены реализуем уже на основе сделанного.

Весь наш тетрис (т.е. стакан и движущиеся фигуры) будет отображаться внутри некоторой прямоугольной области. Каркас BlackBox (далее BB) построен на архитектуре MVC (model-view-controller) и предоставляет заготовки для конструирования собственных (то есть, ваших) объектов отображения, или view. Воспользуемся этим приятным обстоятельством и создадим свой тип объекта отображения на базе стандартного Views.View:

	View = POINTER TO EXTENSIBLE RECORD (Views.View) END;

Условием использования типа Views.View является обязательная реализация абстрактного метода Restore. И это естественно, ведь отображение должно отображаться! Реализуем этот метод, который (для начала) нарисует нам тёмную рамку и светлый фон стакана:

	PROCEDURE (v: View) Restore (f: Views.Frame; l, t, r, b: INTEGER);
		VAR w, h: INTEGER;
	BEGIN
		v.context.GetSize(w, h); 
		f.DrawRect(f.dot, f.dot, w-f.dot, h-f.dot, Ports.fill,Ports.RGBColor (200,200,200));
		f.DrawRect(0, 0, w, h, f.dot, Ports.RGBColor (100,100,100));
	END Restore;

Сразу же сделаем процедуру размещения нашего свежеиспечённого объекта отображения:

	PROCEDURE Deposit*;
		VAR v: View;
	BEGIN
		NEW(v); Views.Deposit(v)
	END Deposit;

Всё вышенаписанное расположим в модуле TetrisViews. Теперь можно выполнить последовательно команды TetrisViews.Deposit; StdCmds.PasteView и наш объект отображения будет вставлен в сфокусированный составной документ. Но каркас заставит наше отображение принять размеры по умолчанию 10х5 мм. Изменим размеры, принимаемые объектом отображения по умолчанию. Для этого необходимо реализовать предпочтение размера путём обработки сообщения типа Properties.SizePref в методе-обработчике сообщений свойств HandlePropMsg:

	PROCEDURE (v: View) HandlePropMsg (VAR msg: Properties.Message);
		CONST min = 5 * Ports.mm; max = 50 * Ports.mm;
	BEGIN
		WITH msg: Properties.SizePref DO
			IF (msg.w = Views.undefined) OR (msg.h = Views.undefined) THEN
				 msg.w := 20 * Ports.mm; msg.h := 40 * Ports.mm
			END
		ELSE
		END
	END HandlePropMsg;

Теперь, наш объект отображения при вставке получает сообщение от каркаса, устанавливает свои размеры 20х40 мм и возвращает эту информацию обратно каркасу. Можно теперь вставить это отображение в любой открытый документ каркаса. Например, в исходный текст модуля:

пример вставки объекта отображения в документ

Настоящий «стакан». Этого вполне достаточно для начала. Небольшое примечание: не стоит сохранять документ с объектом отображения без правильно реализованной перманентности. Перманентность тетриса будет рассмотрена далее.

Следующим шагом будет реализация управления с клавиатуры. Мы будем обрабатывать навигационные клавиши «вверх, вниз, влево, вправо».

	PROCEDURE (v: View) HandleCtrlMsg (f: Views.Frame; VAR msg: Views.CtrlMessage; VAR focus: Views.View);
	BEGIN
		WITH msg: Controllers.EditMsg DO
				IF msg.op = Controllers.pasteChar THEN
					CASE msg.char OF
					| 1FX: Log.String('1F'); (* вниз *)
					| 1EX: Log.String('1E'); (* вверх *)
					| 1DX: Log.String('1D'); (* вправо *)
					| 1CX: Log.String('1C'); (* влево *)
					ELSE
					END;
				END
		ELSE
		END
	END HandleCtrlMsg;

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

Создаём модель

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

Создадим тип модели в модуле TetrisModels:

	Model* = POINTER TO LIMITED RECORD (Models.Model)
		w-, h-: INTEGER; (* размеры стакана, в клетках. 10x18 *)
		cells: ARRAY height OF ARRAY width OF Cell; (* массив ячеек *)
	END;

Тип помечен как LIMITED, то есть, экземпляр может размещаться с помощью NEW только в модуле TetrisModels. Это полезно, т.к. не даёт бесконтрольно создавать экземпляры модели. Для размещения создадим процедуру-конструктор TetrisModels.NewModel:

	PROCEDURE NewModel*(): Model;
		VAR m: Model;
	BEGIN
		NEW(m); m.w := 10; m.h := 18;
		RETURN m
	END NewModel;

Агрегируем модель в наш объект отображения:

	View = POINTER TO EXTENSIBLE RECORD (Views.View)
		model: TetrisModels.Model
	END;

Модифицируем метод Restore для отрисовки содержимого модели:

	PROCEDURE (v: View) Restore (f: Views.Frame; l, t, r, b: INTEGER);
		...
	BEGIN
		...
		RestoreModel(f, v.m, w, h)
	END Restore;

Конечно, вся соль в процедуре RestoreModel. Чтобы не загромождать текст, приведу лишь её суть. Эта процедура рисует внутри прямоугольной области объекта отображения сетку, затем построчно отрисовывает ячейки, с учётом толщины линий сетки. И всё. Как именно это делается, вы сможете легко разобраться в исходном тексте. А выглядит это так:

сетка стакана

Кстати, нам надо рассчитывать пропорции стакана, чтобы при изменении размеров объекта отображения, ячейки всегда оставались квадратными. У нас уже есть модель, поэтому можно предпочтения размеров нашего объекта отображения вычислять по габаритам сетки стакана. Модифицируем обработчик сообщений свойств:

	PROCEDURE (v: View) HandlePropMsg (VAR msg: Properties.Message);
		CONST min = 5 * Ports.mm; max = 50 * Ports.mm;
	BEGIN
		WITH msg: Properties.SizePref DO
			(* Пропорции подбираем по размерам модели *)
			IF (msg.w > Views.undefined) & (msg.h > Views.undefined) THEN
				Properties.ProportionalConstraint(v.m.w, v.m.h, msg.fixedW, msg.fixedH, msg.w, msg.h);
				IF msg.w < 10 * Ports.mm THEN
					msg.w := 10 * Ports.mm; msg.h := msg.w
				END
			ELSE
				msg.w := 30*Ports.mm; msg.h := SHORT(ENTIER((v.m.h / v.m.w)*msg.w));
			END
		ELSE
		END
	END HandlePropMsg;

Теперь, если пользователь пожелает изменить габариты стакана, то ширина и высота будут изменяться пропорционально.

Создаём фигуры

Внимательно присмотримся к классическому тетрису, созданному Пажитновым. Мы увидим, как фигуры поворачиваются интересным образом вокруг некоего центра вращения. Причём у некоторых фигур этот центр определён произвольным образом, а фигура «палка» при поворотах вообще смещает центр вращения. Отсюда можно сделать вывод, что универсальный алгоритм вращения будет довольно запутанным. Поэтому мы пойдём максимально лёгким путём. Представим фигуру как набор фаз и при повороте будем просто выбирать следующую фазу. Фаза - это, по сути, маска ячеек. Массив из значений «1» и «0». Накладываем этот массив на сетку стакана, и где встречается значение «1», там рисуем ячейку. Алгоритм рисования передвинутой или повёрнутой фигуры при таком подходе будет крайне простым:

  • Шаг 1. Стереть ячейки по маске.
  • Шаг 2. Передвинуть маску или выбрать другую фазу.
  • Шаг 3. Нарисовать ячейки по маске.

Но наши фигуры должны не просто вращаться или двигаться. Они должны уметь определять препятствие. Наш алгоритм немного усложнится:

  • Шаг 1. Стереть ячейки по маске.
  • Шаг 2. Передвинуть маску или выбрать другую фазу.
  • Шаг 3. Проверить, можно ли рисовать в новом положении маски. Если нет, то вернуть маску обратно.
  • Шаг 4. Нарисовать ячейки по маске.

Ниже приведён интерфейс модуля TetrisFigures. Тип фазы Phase объявлен как указатель на двумерный байтовый массив. Габариты маски определяются в момент создания, поэтому Phase является указателем на массив, и память под фазу выделяется динамически.

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

DEFINITION TetrisFigures;
 
	CONST
		blue = 11741995;
		brown = 543959;
		cyan = 10464269;
		green = 693002;
		grey = 14474460;
		purple = 14434266;
		yellow = 1106913;
 
	TYPE
		Figure = POINTER TO LIMITED RECORD 
			w-, h-: INTEGER;
			row, col, color-: INTEGER;
			(f: Figure) AddPhase (phase: Phase), NEW;
			(f: Figure) NextPhase, NEW;
			(f: Figure) Phase (): Phase, NEW;
			(f: Figure) PrevPhase, NEW;
			(f: Figure) SetPhase (n: INTEGER), NEW
		END;
 
		Phase = POINTER TO ARRAY OF ARRAY OF BYTE;
 
	PROCEDURE Fig1p1 (): Phase;

Падение фигур

Сделаем падение фигур на дно стакана.

Ещё раз присмотримся к классическому тетрису. Фигуры в нём падают, ритмично и построчно передвигаясь всё ниже и ниже, пока не доберутся до дна стакана или не натолкнутся на препятствие. Переход на следующую строку является дискретным и выполняется с периодичностью примерно раз в секунду. Фигура падает совершенно самостоятельно. Ясно, что нам нужен периодический источник неких событий. Каркас BlackBox предоставляет возможность создавать так называемые «отложенные действия». Это объекты, метод Do которых вызывается самим каркасом, отложенным образом.

Для сдвига на строку вниз, делаем метод

	PROCEDURE (m: Model) MoveDown*, NEW;
	BEGIN
		MaskFigure(m, {clear});
		INC(m.f.row);
		IF ~CheckFigure(m) THEN 
			DEC(m.f.row);
			MaskFigure(m, {draw, stop}); TetrisGame.ReachBottom; 
		END;
		MaskFigure(m, {draw});
	END MoveDown;

Обратите внимание, что в этом методе реализуется вышеописанный алгоритм отрисовки фигуры. Сперва мы затираем ячейки по маске (MaskFigure(m, {clear})), затем вычисляем номер нижележащей строки, проверяем возможность отрисовки в новой позиции и рисуем фигуру (MaskFigure(m, {draw})). Кстати, тут уже виден момент использования модуля игровой логики TetrisGame, но об этом в следующем разделе.

Запустим отложенное действие, которое будет периодически уведомлять отображение стакана о том, что пора передвинуть фигуру на строчку вниз. Объект отображения получит сообщение, и заставит модель отработать сдвиг. Но сперва создадим своё действие на основе типа Services.Action:

	StepDownAction = POINTER TO RECORD (Services.Action) END;

Теперь нам нужен особый тип сообщений, для уведомления о необходимости сдвинуть фигуру на строку вниз:

	StepDownMsg = RECORD (Models.Message) END;

В интерфейсе Services.Action имеется абстрактная процедура Do, которую необходимо реализовать. Это условие использования типа. Процедура выполнится в указанный момент времени, отослав уведомление отображению.

	PROCEDURE (a: StepDownAction) Do-;	
		VAR msg: StepDownMsg;
	BEGIN
		Views.Omnicast(msg);
	END Do;

Небольшой комментарий к процедуре Do. Поскольку мы не имеем понятия, в каком документе открыт объект отображения стакана, то воспользуемся широковещательной процедурой Views.Omnicast(msg). Она рассылает сообщение msg всем открытым объектам отображения. Конкретный тип сообщения StepDownMsg основан на типе Models.Message, поэтому каркас BlackBox будет рассматривать msg как сообщение модели и вызывать соответствующий обработчик. Среагируют же на данное сообщение только те объекты отображения, которые «понимают» тип StepDownMsg. То есть, на сообщение о сдвиге фигуры отреагирует только наш стакан!

Агрегируем отложенное действие в объект отображения стакана:

	View = POINTER TO EXTENSIBLE RECORD (Views.View)
		stepAction: StepDownAction;
		...
	END;

Модифицируем процедуру размещения объекта отображения, для непосредственного запуска отложенного действия:

	PROCEDURE Deposit*;
		VAR v: View;
	BEGIN
		NEW(v); v.m := TetrisModels.NewModel(); 
		NEW(v.stepAction); Services.DoLater(v.stepAction, Services.Ticks()+Services.resolution);
		Views.Deposit(v)
	END Deposit;

Отложенное действие, созданное и зарегистрированное при выполнении процедуры Deposit, сработает ровно через секунду, отослав сообщение StepDownMsg. Чтобы наш объект отображения получил сообщение от каркаса, и смог обработать его, реализуем метод

	PROCEDURE (v: View) HandleModelMsg (VAR msg: Models.Message);
	BEGIN
		WITH msg: StepDownMsg DO
			v.m.MoveDown; Views.Update(v, FALSE);
			Services.DoLater(v.stepAction, Services.Ticks()+Services.resolution)
		ELSE
		END
	END HandleModelMsg;

Как видно, при получении сообщения StepDownMsg, выполняется сдвиг фигуры вниз, затем объект отображения обновляется для того, чтобы игрок увидел изменения. И отложенное действие регистрируется опять, что и определяет периодичность сдвига фигур.

Игровая логика

В предыдущем разделе мы увидели, как модель отрабатывает сдвиг фигуры вниз. Если на пути падающей фигуры встречается препятствие, то это повод прекратить падение. Что же должно произойти в момент остановки падающей фигуры?

Как подсказывает нам классика, если фигура достигла дна или препятствия, то нужно уничтожить полностью заполненные строки стакана, уронить ранее уложенные ячейки построчно на освободившееся место и создать новую фигуру в верхней части стакана. Все эти действия можно было бы сделать сразу в методе модели MoveDown. Но, во-первых, не стоит усложнять простые вещи, ведь MoveDown отвечает только за падение фигуры и сигнализирование о достигнутом препятствии. Что должно произойти дальше, является заботой других компонентов системы. Во-вторых, просто необходимо передать сигнал о достижении дна в модуль игровой логики. Поэтому, мы идём стандартным путём и создаём сообщение модели в модуле TetrisGame, и процедуру, обрабатывающую сигнал о прекращении движения фигуры:

	ReachBottomMsg* = RECORD (Models.Message) END
 
	PROCEDURE ReachBottom*;
		VAR msg: ReachBottomMsg;
	BEGIN
		Views.Omnicast(msg);
	END ReachBottom;

В предыдущем разделе мы видели, как вызывается эта процедура в методе MoveDown. Настала пора рассмотреть дальнейший процесс. Процедура ReachBottom посылает всем открытым объектам отображения сообщение о достижении «дна», которое обрабатывается в нашем отображении стакана:

	PROCEDURE (v: View) HandleModelMsg (VAR msg: Models.Message);
	BEGIN
		WITH ...
		| msg: TetrisGame.ReachBottomMsg DO
			v.m.BurnRows; 
			v.m.NewFigure 
		...
	END HandleModelMsg;

Итак, ещё разок окинем взглядом получившуюся схему.

Объект отображения получает сообщение о сдвиге фигуры и заставляет модель сдвинуть фигуру вниз на одну строку. Модель сдвигает фигуру, но если это невозможно, то срабатывает модуль игровой логики и посылает сообщение о достижении низа. Это сообщение снова отрабатывается объектом отображения, который заставляет модель уничтожить заполненные строки и создать очередную фигуру.

Движение фигур в стороны и повороты делаем с помощью методов модели MoveLeft, MoveRight, Rotate. Их мы, ничтоже сумняшеся, вызываем в обработчике сообщений контроллера HandleCtrlMsg, который теперь выглядит примерно так:

PROCEDURE (v: View) HandleCtrlMsg (f: Views.Frame; VAR msg: Views.CtrlMessage; VAR focus: Views.View);
		VAR stepDownMsg: StepDownMsg;
	BEGIN
		WITH msg: Controllers.EditMsg DO
			msg.requestFocus := TRUE;
			IF msg.op = Controllers.pasteChar THEN
				CASE msg.char OF
				| 1FX: Views.BroadcastModelMsg(f, stepDownMsg); (* вниз *)
				| 1EX: v.m.Rotate (* вверх *)
				| 1DX: v.m.MoveRight (* вправо *)
				| 1CX: v.m.MoveLeft (* влево *)
				ELSE
				END;
				Views.Update(v, FALSE)
			END
		ELSE
		END
	END HandleCtrlMsg;

Наконец-то, наш стакан заработал как надо. Новые фигуры появляются в верхней части стакана, падают вниз и останавливаются. Игрок может двигать фигуры вправо-влево и вращать их, добиваясь наилучшего заполнения пространства стакана. Кроме этого, можно нажимать кнопку «вниз», чтобы ускорить падение фигуры. Как видно, это делается элементарным отсылом сообщения StepDownMsg (правда, не через Omnicast, а через Views.BroadcastModelMsg. Потому что отображение-адресат в этот момент нам известен — ведь по сути, объект отображения посылает сообщение самому себе!).

Тетрис заработал!

Правда, у нас только один-единственный тип фигуры и не анализируется окончание игры, но это легко поправимо. В модуле TetrisFigures добавляем возможности создания новых фаз, модифицируем метод модели NewFigure для случайного выбора фигуры и анализа заполнения стакана. В этом же методе, после создания фигуры, сразу же проверяем наличие препятствия. Если стакан уже заполнен настолько, что фигура не сможет двигаться, то наступает конец игры, о чём и сообщаем модулю TetrisGame:

	PROCEDURE(m: Model) NewFigure*, NEW ;
	BEGIN
		...
		MaskFigure(m, {draw});
		IF ~CheckFigure(m) THEN Game.TheEnd END;
	END NewFigure;

Когда игра закончилась, то надо об этом всем рассказать. Глобальную экспортированную переменную TetrisGame.state.end устанавливаем в TRUE:

	PROCEDURE TheEnd*;
	BEGIN
		state.end := TRUE;
	END TheEnd;

И модифицируем обработчик сообщения TetrisViews.View.StepDownMsg:

	...
	WITH msg: StepDownMsg DO
		Services.RemoveAction(v.stepAction);
		v.m.MoveDown;
		Views.Update(v, Views.keepFrames);
		IF ~TetrisGame.state.end THEN Services.DoLater(v.stepAction, Services.Ticks()+Services.resolution) END
			...

Вот теперь, при окончании игры, отложенное действие просто не включится и процесс остановится. Чтобы запустить игру заново, надо сообщить отображению об этом. Вводим новый тип сообщений RestartMsg, процедуру TetrisGame.NewGame

	PROCEDURE NewGame*;
		VAR msg: RestartMsg;
	BEGIN
		state.end := FALSE;
		Views.Omnicast(msg);
	END NewGame;

и дорабатываем обработчик сообщений модели нашего отображения:

	PROCEDURE (v: View) HandleModelMsg (VAR msg: Models.Message);
	BEGIN
		WITH ...
		| msg: TetrisGame.RestartMsg DO 
			v.m.Clear;
			v.m.NewFigure;
			Services.DoLater(v.stepAction, Services.Ticks()+Services.resolution);
			Views.Update(v, Views.keepFrames);
		...
	END HandleModelMsg;

Ну, а после всего этого, при получении сообщения о рестарте игры, стакан будет очищен, потом создана новая фигура и запущено отложенное действие. И, желательно бы сразу обновить наше отображение с помощью Views.Update. Вот что получается в итоге:

 уже рабочий тетрис

Перманентность

Если есть надобность просто поиграть :-), то размещать тетрис-отображение в текстовых документах неудобно. Поэтому сделаем диалоговую форму, на которой разместим отображение тетриса, кнопки «Новая игра» и «Выход», а впоследствии, ещё и изображение очередной фигуры и счёт. Наша форма будет сохранена как составной документ. Чтобы наше тетрис-отображение тоже сохранилось, а при открытии документа загрузилось, необходимо реализовать два метода из интерфейса Views.View:

	PROCEDURE (v: View) Internalize (VAR rd: Stores.Reader);
	BEGIN
		v.m := TetrisModels.NewModel(); 
		NEW(v.stepAction); 
		v.stepAction.v := v;
	END Internalize;
 
	PROCEDURE (v: View) Externalize (VAR wr: Stores.Writer);
	BEGIN
	END Externalize;

Первый из них, вызывается средой при загрузке отображения. Второй, вызывается при сохранении, поэтому его тоже необходимо реализовать для корректной работы каркаса.

Создаём диалоговую форму, сохраняем в документ, и открываем в режиме маски:

	StdCmds.OpenAuxDialog('Tetris\Rsrc\Form', 'Тетрамино')

Вот тут мы и обнаруживаем штуку. Наш тетрис, оказывается, не реагирует на нажатия стрелочных кнопок! Дело в том, что сообщения о нажатиях кнопок передаются в текущий фокус. А наша диалоговая форма является сфокусированным отображением, и тоже имеет метод HandleCtrlMsg, в котором обрабатывает сообщения контроллера. Но не передаёт их нашему тетрис-отображению! Эту ситуацию можно разрешить, если обработать сообщение Properties.ControlPref, посылаемое каркасом открытым объектам отображения:

	PROCEDURE (v: View) HandlePropMsg (VAR msg: Properties.Message);
	BEGIN
		WITH ... | msg: Properties.ControlPref DO
			msg.accepts := TRUE;
		ELSE
		END
	END HandlePropMsg;

Теперь отображение тетриса будет постоянно требовать себе фокус, и сообщения контроллера будут перенаправляться ему. Ну а результат вышеописанных трудов выглядит примерно так:

 Результат

Спасибо за внимание. Успехов в использовании BlackBox Component Builder!

27 апреля 2009г.

См. также

© 2005-2016 OberonCore и коллектив авторов.