Introduction
In this article I'll cover a way to use Dart in order to implement a HTML5 Tetris-like game.
As I've covered the basics of web game development using Dart before, the main focus of this article will be implementing Tetris-like game logic and actions.
There's an early version compiled to JavaScript if you want to have a look at what it looks like in action.
Using the code
The zip-archive contains a Dart Editor project, just unzip and import.
Requirements
I defined a set of requirements for my Tetris implementation:
- Some form of effects; I wanted something extra to happen when a row was cleared rather than just the blocks dissappearing.
- Wall kick; A feature of some Tetris-like games is that if the player tries to rotate a piece while it's too close to the edge of the playing field, it will be "kicked" inwards towards the center of the playing field (provided there's room there).
- Sound; The game should play sounds as different events take place.
- Configurable keys; One of the hardest (I find) part of game development is to finish everything that's not game-play specific, such as intros and menus, so for this one I'm going to have to add at least a basic way of configuring the keys.
- Twist; The game should not be a bulk-standard version of Tetris, but should have some twist to it.
For the Twist part I ended up doing the double-thingy, where the player controls two games at once, one upside-down and the other the right way up.
Fundamentals
In Tetris the players rotates and moves pieces all made up of four squares.
The rule for how pieces look or are constructed is that each square must be adjecant to another square, and they must be sharing at least one full side with at least one neighbour.
There are seven distinct configurations that the squares can occupy, had there been three squares in stead of four
it would have been three and for five squares a total of *something configurations exists.
Because of the simple rule it is possible to generate all possible pieces and while this would be the approach I would have taken if the pieces were made up of five or more squares
it is not required when there are only seven possible pieces.
It is quicker and tidier (I think) to manually define the seven pieces.
In Double-Dartris there are three classes that are fundamental;
Block
; which represents a single square or part of a falling piece. Piece
; which represents a falling piece by being a set of Block
s. PlayingField
; which represents the area where the Piece
s fall and where the Block
s stack up.
The Code
Block
The Block
class represents a single square, either as part of a falling Piece
or as a Block
that has
settled somewhere in the PlayingField
. The reason I need both Block
s and Piece
s is that the characteristics
of a Piece
changes drastically as soon as it's settled, and clearing a row can clear part of a Piece
.
Because the Block
is an abstraction of such a basic thing it essentially contains only two things;
In addition to holding the above data, it also knows how to render itself.
Notice that it does not have a concept of size, something that would be required for rendering. This is because the block has an implicit size of 1x1, and the render
method
knows how to translate and scale that into the correct onscreen position. It does this by projecting a game-area onto a screen:
void render(final CanvasRenderingContext2D context, final Rect screen, final Rect gameArea) {
final int w = screen.width ~/ gameArea.width;
final int h = screen.height ~/ gameArea.height;
final Rect block = new Rect(w * (gameArea.left + position.x), h * (gameArea.top + position.y ), w, h);
fillBlock(context, block, _color);
}
In the above the screen
is a Rect
in pixels, and the gameArea
is in
field units, i.e. if the game is ten blocks wide and twenty blocks tall gameArea
is 10x20.
Doing it this way decouples the game-play logic from the rendering logic, the Block
can be rendered in a small area
or fullscreen and it would still look correct as the width w
and height h
are calculated as the distances you'd
get if gameArea
was stretched onto screen
.
The ~/
operator is a Dart convenience operator that gives the integer result of a division,
it is a faster, shorter version of normal division plus an explicit int conversion:
final int w = screen.width ~/ gameArea.width;
final int w = (screen.width / gameArea.width).toInt();
The full listing for Block
looks like this:
class Block {
final Position position;
final ColorPair _color;
Block(this.position, this._color);
String toString() => "B@$position";
get hashCode => position.hashCode;
operator ==(final Block other) {
return position == other.position;
}
void render(final CanvasRenderingContext2D context, final Rect screen, final Rect gameArea) {
final int w = screen.width ~/ gameArea.width;
final int h = screen.height ~/ gameArea.height;
final Rect block = new Rect(w * (gameArea.left + position.x), h * (gameArea.top + position.y ), w, h);
fillBlock(context, block, _color);
}
}
It might seem peculiar that two Block
s are considered the same if their positions are the same
regardless of their color, but the position in the PlayingField
is what makes a block unique (as will be apparent when I cover PlayingField
later).
Piece
The Piece
class represents the group of four Block
s that is currently falling across the PlayingField
.
It delegates the rendering to the render
method on Block
.
The shape of the piece is defined by it's final List<Block> _positions;
member that hold the blocks making up the default configuration of the
Piece
. Using properties for position and rotation in the PlayingField
the Piece
transforms the default Block
s to the
current representation on every rendered frame. This is obviously not very efficient but it shouldn't really matter for a game as simple as a Tetris.
List<Block%gt; _getTransformed() {
final List<Block%gt; transformed = new List<Block%gt;();
for(int i = 0; i < _positions.length; ++i) {
Block block = _positions[i];
for(int r = 0; r < _rotation; ++r) {
block = new Block(new Position(-block.position.y, block.position.x), block._color);
}
transformed.add(new Block(_position + block.position, block._color));
}
return transformed;
}
void render(final CanvasRenderingContext2D context, final Rect screen, final Rect gameArea) {
final List<Block%gt; transformed = _getTransformed();
for(int i = 0; i < transformed.length; ++i) {
final Block block = transformed[i];
block.render(context, screen, gameArea);
}
}
Another responsibility of the Piece
class is to accept move and rotate requests from the controller.
Instead of checking if a move is valid before the move (by valid I mean not moving outside the bounds of the PlayingField
or into a Block
), all moves
are accepted and upon completion the validity of the current state of the PlayingField
is checked and if found to be invalid it's rolled back to the previous state.
Using this try-rollback method the individual methods for moving (move horizontally, soft drop and rotate) become fairly simple and essentially only delegate a very small payload
to a method called _tryAction
.
bool _tryAction(void transaction(), void rollback(), final Set<Block%gt; field, final Rect gameArea) {
transaction();
final Iterable<Block%gt; transformed = _getTransformed();
final int lastLine = falling ? gameArea.bottom : gameArea.top - 1;
if (transformed.any((b) =%gt; b.position.y == lastLine || b.position.x < gameArea.left || b.position.x %gt;= gameArea.right || field.any((fp) =%gt; fp == b))) {
rollback();
return true;
}
else {
return false;
}
}
Two delegates are used for the action and the rollback, first transaction
is executed (and this mutates the position and/or the rotation)
and after the state has been checked using the transformed version, transformed using the new parameters, it is either left that way
or rolled back if found to be invalid. To rollback the rollback
delegate is invoked.
Because of the try-rollback approach the code for moving a Piece
becomes a single line with two delegates that are equal and opposite.
bool move(final Position delta, final Set<Block> field, final Rect gameArea) {
return _tryAction(() => _position += delta, () => _position -= delta, field, gameArea);
}
Adjust the _position
by a _delta
amount, and if it doesn't yield a valid state then rollback by adjusting by the negative _delta
.
Rotating a piece would be similarly simple if it wasn't for the wall-kick feature that moves the piece inwards if the rotation is blocked by the walls.
To wall-kick the try-rollback action is attempted as many times as there are Block
s in the Piece
, that makes sure that all required
positions of kick have been tried as there's never any need to kick more than that to find a clear space.
For each iteration the rotation is applied and the kick distance is increased from 0 to number of Block
s, if the rotation was valid it stays, otherwise
it's rolled back and tried again with a higher value for the kick distance.
void _wallKickRotate(final Position kickDirection, final bool rollback) {
_rotation += rollback ? -1 : 1;
if (rollback)
_position -= kickDirection;
else
_position += kickDirection;
}
bool rotate(final Set<Block> field, final Rect gameArea) {
final int originalX = _position.x;
for(int i = 0; i < _positions.length; ++i) {
final Position kickDirection = new Position(_position.x < gameArea.horizontalCenter ? i : -i, 0);
if (!_tryAction(() => _wallKickRotate(kickDirection, false), () => _wallKickRotate(kickDirection, true), field, gameArea)) {
if (_position.x != originalX)
_audioManager.play(_position.y % 2 == 0 ? "wallKickA" : "wallKickB");
return true;
}
}
return false;
}
Again, this is not optimizing for performance at all and there are obviously much more efficient ways of doing this.
PlayingField
The PlayingField
class represents the area the Piece
s fall in and Block
s occupy.
It accepts requests to move, rotate and drop the current Piece
and uses a factory to generate the next Piece
when the current one settles in the field into Block
s.
The field is also responsible for checking if any rows are fully occupied by Block
s and should be collapsed.
The main purpose of the class is game-logic so it doesn't calculate the new score when a row is collapsed, it simply returns the Block
s
that were collapsed. The reason it returns the blocks and not just a number of how many collapsed is that the special effects require the position
of the collapsed Block
s and I don't want the PlayingField
to know about the special effects, that's not game-logic.
Further, the PlayingField
is in charge of detecting the game over state, it also knows how to render itself but that action is delegated to the
render
methods on Piece
and Block
.
Storing the Blocks
Instead of a two-dimensional array to represent the field I decided to try something a little bit different; the Block
s in the PlayingField
are kept in a set.
final Set<Block> _field = new HashSet<Block>();
This is why the equals overload on Block
only consider position and not color.
I did it this way to try to use the LINQ-like functions on Iterable
instead of accessing a grid-like structure.
There isn't anything particular clever about that approach, I just wanted to see what it would look like implementing it that way.
Collapsing a row
To collapse a row the field will iterate through all rows from the top (or bottom for the flipped side) and if the number of Block
s on
one row equals the width of the _gameArea
(i.e the number of possible horizontal Block
s) then the row is cleared and anything above is moved down one step.
Since I don't care about the performance the Block
s above the cleared row aren't actually moved, they're deleted and readded, that's an effect of
the Block
class being immutable.
Iterable<Block> checkCollapse() {
final int rowDirection = _isFalling ? 1 : -1;
final Position gridDelta = new Position(0, rowDirection);
final int firstRow = _isFalling ? _gameArea.top : _gameArea.bottom;
final int lastRow = _isFalling ? _gameArea.bottom : _gameArea.top - 1;
final Set<Block> collapsed = new HashSet<Block>();
for(int r = firstRow; r != lastRow; r += rowDirection) {
final Iterable<Block> row = _field.where((block) => block.position.y == r).toList();
if (row.length == _gameArea.width) {
collapsed.addAll(row);
_field.removeAll(row);
final Iterable<Block> blocksToDrop = _field.where((block) => _isFalling ? block.position.y < r : block.position.y > r).toList();
_field.removeAll(blocksToDrop);
_field.addAll(blocksToDrop.map((block) => new Block(block.position + gridDelta, block._color)));
}
}
return collapsed;
}
The process of checking for the game over state is as simple as checking if any Block
s are settled on the first row of the playing field.
FieldController
The class that is the glue between the game state (StateGame
) and the game logic (PlayingField
) is the FieldController
.
The FieldController
is responsible for reading and relaying user input to the PlayingField
as well as creating and maintaining the graphical effects
that are not directly related to game logic (in Double-Dartris those effects are messages that animate when multiple rows have been cleared in one drop).
Having a controller provides a neat de-coupling between the abstraction of the game and the interface with the user, and it allows for things like adding an AI-player.
Since the PlayingField
class cares about what can be done in the game (move left, move right, drop, rotate, clear row or lose the game) it shouldn't have any intimate
knowledge about what initiates those things, that's the controller's job. Even the dropping of a Piece
is the job of the controller, the field only knows how to drop and
whether or not the Piece
has settled into Block
s.
As the game "ticks" whenever the browser provides an animation frame the controller needs to keep track of elapsed time and only request a drop when enough time (as dictated by the current level)
has passed.
There are various helper methods in FieldController
but the relevant method is control
which looks like this:
Iterable<Block> control(final double elapsed, final Rect screen) {
if (isGameOver)
return new List<Block>();
_accumulatedElapsed += elapsed;
_checkRotating();
_checkMoving();
_dropInterval = (1.0 / level);
double dropTime = _dropInterval * (isDropping ? 0.25 : 1.0);
dropTime = min(dropTime, isDropping ? 0.05 : dropTime);
if (_accumulatedElapsed > dropTime) {
_accumulatedElapsed = 0.0;
score += level;
if (_field.dropCurrent()) {
final Iterable<Block> collapsed = _field.checkCollapse();
score += collapsed.length * collapsed.length * level;
_field.next();
final numberOfRowsCleared = collapsed.length ~/ _field._gameArea.width;
switch(numberOfRowsCleared) {
case 0: _audioManager.play("drop"); break;
case 2: _audioManager.play("double"); break;
case 3: _audioManager.play("triple"); break;
case 4: _audioManager.play("quadruple"); break;
}
final TextEffect effect = _buildTextEffect(numberOfRowsCleared, screen);
if (effect != null)
_textEffects.add(effect);
return collapsed;
}
}
return new List<Block>();
}
Essentially what the method does is:
Process Horizontal Move
Process Rotate
If it is time for Piece to Drop Then
Drop Piece
If dropped piece settled then
Collapse rows
Play some sounds
End If
If applicable Then Create Text Effect
Generate Next Piece
End if
The controller is constructed with (amongst other things) the keys that will control the Piece
,
that makes it easy to have the keys used in the game configurable which was one of the requirements I set out to cover.
The FieldController
(or actually a pair of them) are used by the main state StateGame
.
StateGame
The game uses a state machine much like the one I described in my previous article on Dart game development, and while the StateGame
is the most interesting state, the full state machine looks like this:
The main state wraps up two FieldController
s (one falling and one rising game) and controls the animation that
takes place when a row is cleared or the game is over.
It is also where the game interacts with the HTML elements used to display most of the game's text. I wanted that portion
out of both the PlayingField
and the FieldController
as writing unit tests becomes harder when the code couples to
the DOM-tree. Granted, I have wrapped up the calls to set and clear the text in a way that could be extended to allow mocking but I thought it overkill
to go all the way for something as simple as a Tetris game.
Text Effects
When two or more rows are cleared in one drop a text message saying "Double", "Triple" or "Quadruple" fades in and out quickly whilst also being animated.
These effects are owned by the PlayingField
and there are two classes that make up the text effect implementation.
Text
The Text
class represents the text in a single state, and is able to render the text in that state. By state I mean properties such as:
- Font size
- Position
- Rotation
- Alpha blend
In addition to the above the string that is the text and the color is also available on the Text
class, but they're not allowed to be animated.
As the transform can be set (position and rotation together make up the transform) for the Text
and since the transform is set globally, i. e. for all
subsequent draws on a CanvasRenderingContext2D
, the Text
's render
method saves the current render transform before applying the
properties of the text. Then, when it has rendered it's own current state it restores the previous transform.
The render
method takes the properties of the Text
and applies them to the CanvasRenderingContext2D
, so _position
becomes the translation,
_fontSize
and _font
become the font, etc.
void render(final CanvasRenderingContext2D context, final Rect screen) {
context.save();
context.translate(_x, _y);
context.rotate(_angle);
context.globalAlpha = _alpha;
context.fillStyle = _color.primary.toString();
context.shadowColor = _color.secondary.toString();
context.shadowOffsetX = 2;
context.shadowOffsetY = 2;
context.font = "${_fontSize}px ${_font}";
context.textAlign = _align;
context.textBaseline = "middle";
context.fillText(_text, 0, 0);
context.restore();
}
So while the Text
class holds and renders a single "state", there's another class that mutates that state, the TextEffect
class.
TextEffect
The TextEffect
class has two very simple responsibilities;
- Keep track of how long the effect lasts for, the duration.
- Mutate the state of it's
Text
using animators.
The duration (in seconds) is passed as a double
to the TextEffect
constructor and on every update the
elapsed time is added to a cummulative _elapsed
field. When _elapsed
is greater than _duration
the
effect is complete and can be removed from the collection owned by the FieldController
.
For each frame the fraction between elapsed and duration is calculated, and it is this fraction that is fed as input to the animators that in turn yield new values for the
properties of the Text
.
The animators are simple function pointers
typedef double DoubleAnimator(final double _elapsed);
class TextEffect {
Text _text;
double _elapsed = 0.0;
double _duration;
DoubleAnimator _sizeAnimator;
DoubleAnimator _xAnimator;
DoubleAnimator _yAnimator;
DoubleAnimator _angleAnimator;
DoubleAnimator _alphaAnimator;
...
}
This way the update
method of TextEffect
will update, for example, the X part of the
Text
's position like this:
void update(final double elapsed) {
_elapsed += elapsed;
final double fraction = _elapsed / _duration;
_text._x = _xAnimator(fraction).toInt();
...
}
As the value passed in to the animator is the fraction between _elapsed
and _duration
the animators
are not supposed to answer the question "what is the state after N seconds?" but rather answer "what is the state after P% of the duration has passed?".
Doing it this way makes it easy to manipulate the properties of the text.
As an example; the text effect played when the player clears four rows in one drop looks like this:
final TextEffect effect = new TextEffect(new Text("QUADRUPLE!", "pressstart", "center", color), 1.0);
effect._sizeAnimator = (f) => 10.0 + f * 30;
effect._xAnimator = (f) => screen.horizontalCenter;
effect._yAnimator = (f) => screen.verticalCenter;
effect._angleAnimator = (f) => sin(f * 2 * PI);
effect._alphaAnimator = (f) => 1.0 - f;
This sets up a text that grows from 10.0 to 30.0 points and wobbles a bit whilst fading out in a linear fashion.
Points of Interest
Summary
I think I managed to complete the requirements I set out to implement but as this is only my second attempt at a Dart program/game
the code got messier than I wanted. I don't think it's aweful but as I go I discover ways of doing things that suits Dart better.
What this second project has reconfirmed for me though is that because of the similarities between Dart and languages I normally use
on a daily basis (Java, C#) I am alot more efficient when producing stuff for the web than I would be in JavaScript. But still,
as I discussed in my previous Dart article, I find the lack of resources and tooling for Dart lacking.
Hindsight is always 20-20
As in Double-Dartris one game falls and the other rises my Piece
, PlayingField
and FieldController
all
care about what is up and down and what direction a Piece
falls in. What I should have done was to not do that as it convolutes the code
for very little upside. The smarter thing would have been to take care of the flipping of one game exclusively in the rendering and kept the game logic the same for both.
That would have essentially cut down unit-testing effort by half.
History
- 2014-04-24; First version.