Кузьмицкий И. А. Создаём тетрис в среде BlackBox Component Builder. Translator: Khaliullin N.

Kuzmitskiy I. A. Making Tetris in BlackBox Component Builder

For a start, we need a problem statement, albeit small.

  1. The playing field is a grid 10×18.
  2. Each game piece (figure) is composed of square blocks — cells. Classic Tetris (tetramino) suggests a set of 7 figures.
  3. On «rotate» command (pressing «up» button), the figure is drawn rotated by 90º.
  4. Rotation of any figure occurs around the cell closest to the geometric center of the figure.
  5. With every step falling figure moves one line down.
  6. The figure begins to fall from the top of the field, where it is located entirely.
  7. Falling stops when any element of the figure touches the underlying element or the floor.
  8. The “Down” button accelerates the fall. «Right» and «Left» buttons move the figure to the right and the left respectively, one cell per step.

The cells can move, or stand still. So, we set that the cell may have a «mobility» status. When the mobility is on, the cell falls to the bottom of the glas. When it is off — cell «hangs» in one place.

Further. We have a simultaneous movement of multiple cells of one figure. Movement stops, if at least one cell of the figure encounters fixed cell.

We can rotate only movable cells. There is some restriction here — rotation can not be done, if after the turn one or more cells become outside of the right or left edge, or overlap another cell.

Let define the entity “figure”, for which the size of bounding square is specified. Any figure movement is movement of the cells inside this bounding square. Rotation is made by simple phase selection. Different figures have different number of phases. We’ll make the figure not as view, but as part of model. We made this decision, because there is no need to display figure separately. The figure is always displayed as part of the model.

"Visual frame" constructing

Let's get started. Below are fragments of original text. Fragments — for not to obstruct the text with details, that can be found in accompanying source code.

To implement the idea, we have to do the following:

  1. Create suitable structures to store data for grid, cells and other entities.
  2. Programm game elements behavior.
  3. Display the contents of the game scene on the screen.
  4. Implement interaction with the user (player, just saying).

Everyone knows, how Tetris looks like. So, let's start right away with construction of the blank scene view, and lay the elements of interaction with user. Then we construct a model and make it visible. And implementation of the scene elements behavior will be based on what is already done.

Entire Tetris (i.e. box and moving figures) will be displayed inside some rectangular region. Blackbox frame uses MVC (model-view-controller) architecture, and provides blanks for constructing your own views. We use this pleasant circumstance, and create our own type of view, based on standard Views.View:

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

The use of Views.view type requires implementation of abstract method Restore. And this is reasonable, because the view should be displayed! We’ll implement this method, which (for the start) will draw a dark frame and light glass background:

	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;

Right after that we implement procedure, that deposits our freshly baked view:

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

All above code we place in the module TetrisViews. Now you can execute commands TetrisViews.Deposit; StdCmds.PasteView, and our view will be inserted in the focused document. But framework makes the view to set the default size 10×5 mm. Let’s change the size, that view takes by default. To do this, we need to implement a size preference by processing the message of type Properties.SizePref inside the properties message handler 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;

Now, after inserting, our view receives a message from the frame, sets its size 20×40 mm and returns this information back to the frame. We can insert this view in any open document. For example, in the source code of this module:

example of view insertion in document

It is real «glass». This is enough to start. A quick note: do not save the document with view without correctly implemented permanence. The permanence of Tetris will be reviewed later.

The next step is to implement keyboard control. We will process the navigation keys «up, down, left, right.»

	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'); (* down *)
					| 1EX: Log.String('1E'); (* up *)
					| 1DX: Log.String('1D'); (* right *)
					| 1CX: Log.String('1C'); (* left *)
					ELSE
					END;
				END
		ELSE
		END
	END HandleCtrlMsg;

In fact we have created a visual frame for our Tetris. It lacks only the figures, falling in our “glass” from above. Now we need a model.

Creating model

The grid of the glass with cells we implement as model in terms of BlackBox, and view will draw this grid. In our case, a separate model is implemented just for illustration of MVC architecture, because there is no real need to separate model from view.

Let’s declare model type in module TetrisModels:

	Model* = POINTER TO LIMITED RECORD (Models.Model)
		w-, h-: INTEGER; (* glass dimensions, in cells. 10x18 *)
		cells: ARRAY height OF ARRAY width OF Cell; (* cells array *)
	END;

The type is marked as LIMITED, i.e. it’s instance can be allocated only by calling NEW inside module Tetris.Model. It is useful, for it doesn’t allow uncontrollably create the instance of the model. To allocate the instance, we declare constructor procedure TetrisModels.NewModel:

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

Now we include model in our view:

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

Modify Restore method to draw model content:

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

Of course, the whole point is in procedure RestoreModel. In order not to complicate the text, I’ll cite only its essence. This procedure draws the grid lines within the rectangular area of view. Then, line by line, it draws thе cells, taking into account the thickness of the grid lines. That's all. How to implement it — you can easily find in source code. And it looks like this:

 glass grid

By the way, we have to calculate the glass ratio, to keep cells square when view is resized. We already have a model, so, dimensions of the glass grid can be used to calculate size preference of our view. Let’s modify the handler of properties message:

	PROCEDURE (v: View) HandlePropMsg (VAR msg: Properties.Message);
		CONST min = 5 * Ports.mm; max = 50 * Ports.mm;
	BEGIN
		WITH msg: Properties.SizePref DO
			(* Select proportions according to model sizes *)
			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;

Now, if the user decides to change the glass size, it’s width and height will be changed proportionally.

Creating the figures

Let’s look closely at the classic Tetris, created by Pajitnov. We'll see, that the figures are rotated in an interesting way around some center. And rotation center of the figures is chosen randomly. What is more, the “Stick” figure, during rotation, shifts its center at all . Thereof we can conclude, that the general-purpose rotation algorithm may be very complex. So, we’ll go more easy way. We represent a figure as a set of phases, and just select the next phase for each turn. Phase, in fact, is just the mask of cells, represented by an array of the ​​»1» and «0» values. We overlay this array on the grid of glass, and draw the cell in place, where we meet a value «1». The algorithm to draw the shifted or rotated figure in this case will be very simple:

  • Step 1. Clear masked cells.
  • Step 2. Shift the mask or choose another phase.
  • Step 3. Draw the cells according to the mask.

But our figures should not just rotate or move. They also should be able to identify an obstacle. So, our algorithm becomes a little bit more complicated:

  • Step 1. Clear masked cells.
  • Step 2. Shift mask or choose another phase.
  • Step 3. Check, if it is valid to draw the cells for new mask position. If not, return mask back.
  • Step 4. Draw the cells according to the mask.

Below is an interface of TetrisFigures module. Type Phase is declared as a pointer to a two-dimensional byte array. Mask size is determined at the time of creation, therefore Phase is a pointer to the array, and the memory for phase is allocated dynamically.

Mask moving and cells switching is provided by the model, that receives commands from the view.

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;

Figure fall

Now we implement figure fall to the bottom of the glass.

Let’s again closely look at the classic Tetris. The figures in it are falling rhythmically, line by line moving down, until they get to the bottom of the glass or touch the obstacle. Transition to the next line occurs approximately once per second. Figure falls independently, so, we need a source of periodically occurring events. BlackBox provides resource to create so-called «deferred actions». These are the objects with /Do method, that called by BlackBox deferred way.

To shift to line down, we define the method

	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;

Note that this method implements the above figure drawing algorithm. First we clear the cells according to the mask (MaskFigure (m, {clear})), then calculate the number of the underlying row, check the possibility of drawing in a new position and then draw a figure (MaskFigure (m, {draw})). By the way, here we already see the point of use of game logic module TetrisGame. But more on that in the next section.

Let’s define a deferred action, that will periodically notify the glass view, that it is time to move figure down to the next row. The view receives the message, and makes the model to shift figure. But first we define our action as descendant of the type Services.Action:

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

Now we need a special type of message, to notify the view, that it is time to make new step:

	StepDownMsg = RECORD (Models.Message) END;

The interface of Services.Action has an abstract procedure Do, which must be implemented. This is the use of type requirement. The procedure will be executed at specified time, and then sends a message to view.

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

A little comment to procedure Do. We use the broadcast procedure Views.Omnicast (msg). because we have no idea, in which document is the glass view displayed. The procedure sends a message msg to all opened views. The specific StepDownMsg type based on the Models.Message type, so BlackBox will consider msg as model message, and call the appropriate handler. But only views that “understand” StepDownMsg type message will respond correctly. That is, only our glass will react on figure shift message!

Now we include the deferred action in glass view:

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

We modify view allocation procedure to directly launch deferred action:

	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;

Deferred action created and registered by Deposit procedure will be triggered exactly in one second, and the message StepDownMsg will be sent. To make view receive and process the message, we implement the method

	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;

As you can see, when view receives the StepDownMsg, it shifts the figure one step down and then updates itself to make the changes visible to player. After that, deferred action is registered again, thereby defining figure shift frequency.

Game logic

In previous section we saw, how the model handles “shift figure down” message. If there is an obstacle on the way of figure, it is the reason to stop falling down. And what should happen after the falling figure stops?

As we know from classic Tetris, if the figure has reached the obstacle or bottom of glass, then we should to destroy completely filled rows, drop previously stacked cells to the freed place, and create a new figure at the top of the glass. All these actions could be done once in the model’s method MoveDown. But, the first, there is no need to complicate simple things, because MoveDown is responsible only for the fall of the figure, and for obstacle signaling. What should happen next, concerns to other system components. And the second, it is just required to notify game logic module that the bottom is reached. So, we go standard way and create a model message in the module TetrisGame, and procedure to processes the stop figure signal:

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

In the previous section we saw, how this procedure is called by the method MoveDown. It is time to consider the further process. ReachBottom procedure sends to all open views a message, that bottom is reached. And this message is handled in our glass view:

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

So, let’s once again take a look to the resulting scheme.

The view receives a message to shift figure and makes the model to move the figure one line down. The model tries to shift the figure. If it is not possible, the game logic module is triggered. It sends the messages that bottom is reached. The view processes this message, and makes the model destroy all filled rows, and create another figure.

Horizontal figure movements and its rotation we perform by model methods MoveLeft, MoveRight, Rotate. We call them, undoubtedly, in the controller’s message handler HandleCtrlMsg, which now looks like:

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); (* down *)
				| 1EX: v.m.Rotate (* up *)
				| 1DX: v.m.MoveRight (* right *)
				| 1CX: v.m.MoveLeft (* left *)
				ELSE
				END;
				Views.Update(v, FALSE)
			END
		ELSE
		END
	END HandleCtrlMsg;

Well, finally our glass is running as expected. New figures appear in the top of the glass, falling down and stop. The player can move the figures left and right, rotate them, trying to get the best cup space filling. In addition, you can press the «down» key to hasten the fall of the figure. As you can see, it is done by simply StepDownMsg message sending (though using Views.BroadcastModelMsg instead of Omnicast, because we know the destination view at this point. In fact, the display object sends a message to itself!).

Tetris is running!

However, we have only one type of the figure and do not analyze the end of the game. But it's easy to fix. We add the ability to create new phases in the module TetrisFigures. We modify the NewFigure method of the model, to randomly select figures and to analyze fill of the glass. And here, right after the creation of the figure, we check for obstacles. If the glass is already full, so the figure can not move, then we notify module TetrisGame that the game is over:

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

When the game is over, it is required to tell everyone about it. We set the global exported variable TetrisGame.state.end in TRUE:

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

And modify the handler of TetrisViews.View.StepDownMsg message:

	...
	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
			...

Now, at the end of the game, deferred action just will not turn on and the process stops. To start the game again, you need to notify view about it.

Let’s define a new type of message RestartMsg — procedure TetrisGame.NewGame:

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

and finalize the model event handler of our view:

	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;

And, after all, on a message about restarting the game, the glass will be cleaned, then new figure created and deferred action launched. And, it would be desirable to immediately update our view by Views.Update. That's how it looks finally:

 already working Tetris

Permanence

If we want just to play :-), then it is uncomfortable to place the tetris-view into text documents. Therefore, we'll make an interactive form on which we place Tetris-view, buttons «New Game» and «Exit», and later — another figure and the score. Our form will be saved as the master document. To save also Tetris-view, and load it when the document is opened, two methods of Views.view interface must be implemented:

	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;

The first one is called when environment loads view. The second one is required to save view, so it also must be implemented for correct operation of the framework.

Now we create a dialogue form, save the document and open it in mask mode:

	StdCmds.OpenAuxDialog('Tetris\Rsrc\Form', 'Tetramino')

Here we find a surprise. Our tetris does not respond to the arrow keys! The point is that messages from the keys are transmitted to the current focus. And our dialog form is a focused view and also has HandleCtrlMsg method, that processes controller’s messages. And it doesn’t pass the message to our tetris-view. It can be fixed by handling Properties.ControlPref message, that framework send to all opened views:

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

Now tetris-view will permanently request focus, and controller’s messages will be passed to it. And the result of the above work looks like this:

 Result

Thank you for your attention. Good luck in using BlackBox Component Builder!

April 27, 2009.

Library, Kuzmitskiy I., Khaliullin N., BB, Education

See also

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