Torque 2D/Getting Started/T2DTetrisTutorial
From TDN
| | This article or section needs updating. Parts of this article or section have been identified as out of date. Reason: This tutorial was written for a previous version of TGB and certain steps of may no longer work or are done differently in the current version of TGB. As such it is not recommended for beginner TGB users. For a list of current tutorials, please click here. If you are able to revise this information, please do so and remove this 'update' flag when finished. |
|
[edit] IntroductionIf you've never played or heard of Tetris – is there anyone left that hasn't? Then have a quick read over the Tetris wikipedia page for an overview of the gameplay as I'll be assuming you're familiar with how the game mechanics work in general. Before we start, heres a quick screenshot of what we're aiming for by the end of the tutorial Refering to the above screenshot you should be able to see how the game is split into several distinct areas. On the left hand side we have the main game "well" into which the various Tetris shapes will fall, this is a U shaped container surrounded by a wall of greyish blocks. Looking at the right hand side we have the information section. Here we display the next shape the user will control as well as the current level, score and number of lines completed. |
|
[edit] Getting StartedThis tutorial is for use with the TGB level editor, tested on Beta3. You may find using a script editor that supports debugging useful. I used Torsion to develop this tutorial however any of the IDE's listed in the IDE Guide will do. I will be assuming that you've already worked through the tutorials that were packaged with TGB as well as read through the basic scripting information within this wiki. This tutorial does not go into great depth (or any depth for that matter) about the ways Torque Script works, there are better tutorials and resources for you to learn about that. Instead we'll work through one possible method of implementing Tetris in TGB, emphasis on "one possible method". There are several other ways that have varied benefits, you'll discover those for yourself as you grow more familar with Torque. Throughout this tutorial you will need to edit a few script files as well as create new files, it is assumed unless stated otherwise that all files should be placed within your_TGB_installation_folder/games/Tetris/. For example "edit main.cs" refers to the file your_TGB_installation_folder/games/Tetris/main.cs. Whilst "edit gameScripts/game.cs" refers to the file your_TGB_installation_folder/games/Tetris/gameScripts/game.cs Remember a working game is better than a game you never finished because you spent too much time trying to code everything the "best way". To start with open up TGB and create a new project, name it Tetris, or similar. You will need to create a level called tetris.t2d. You should also set the startGame call in main.cs to open "~/data/levels/tetris.t2d. |
|
[edit] Notes on types of GamesThere are two main types of 2D games:
Static games are games like Sonic The Hedgehog, where all the levels are predefined. Dynamic Games are games like Tetris, where the world isnt loaded. Static games are easier to make with the level builder, Tetris will require we take a more script-based approach.
|
|
[edit] Physics and CollisionsTGB has great built in support for physics and could be used to produce a more unique version of the Tetris gameplay as shown in Chronic logic's Triptych however, we'll be cloning the classic version of Tetris thus won't be touching on the physics side of TGB. TGB has great collision support as well but just like Physics, we won't be needing it. Why? Because we'll be moving pieces a line at a time and checking whether line below has enough space for the piece to move into before moving it. Now if we were making a breakout/arkanoid clone you'd really appreciate the Physics and Collision support TGB provides, although even in that case you might end up overriding the physics with your own to provide a better feeling "classic" clone. Still the good thing about TGB is that both the Physics and Collisions are options, you can use both, either or provide your own.
|
|
[edit] Game AreaFirst we'll change the image for the mainScreenGui control from the TGB logo to our newly created "./images/gamearena" background (which is a 640x480 png file I quickly created in photoshop). Click on the image below, download it to your computer, and save it in your new project directory under the data/images directory.Open TGB and press F10. On the GUI selection dropdown find mainScreenGui. On the right, select "GuiChunkedImageGui -- mainScreenGui" and change the bitmap in properties to our shiney new gameArena file. (note: In newer versions of TGB, mainScreenGui is a GuiControl, not a GuiChunkedImageGui, and does not have an image propery. You can get the same effect by adding a GuiBitmapCtrl. Under the section Parent, set HorizSizing=width and VertSizing=height. Under the section Misc, set bitmap to the name of the background image) Were done for now, we'll leave any other changes to the mainScreenGUI such as controls to display the current level/score/lines until later in the tutorial. Click on File->Save Gui, then click on mainScreen.Gui, and select Save File. IMPORTANT: Due to the nature of the GUI Tool, which edits your GUI's dynamically while the program itself is running, you must make sure that you select an active and working GUI screen before exiting the GUI Editor. In our case, you should click on the Gui Selection DropDown list (middle button in your editor), select the LevelBuilderBase gui, and then press F10 to exit the GUI Builder Tool. |
|
[edit] ShapesIn Tetris you have seven different shapes each of which can be rotated by in 90 degree increments through a full 360 degrees. Looking at the shapes you should be able to see that they're really nothing more than 4 blocks positioned in a certain way. Although we havn't covered it yet, we'll be using a tile map to represent the main game well, which we'll get to shortly. For this reason we'll need to create an image the tilemap can use for the tiles, with each cell in the image representing a different coloured block (including the Wall block as cell 0) Heres the image we'll use to build all the different shapes – this is 128x16 pixels made up of eight 16x16 cells. Remember all image dimensions should be a power of 2. Click on the link below and download it to your /data/images directory.NOTE: If we had not used a tilemap but instead built shapes out of a collection of sprites, then you could get away with having only a single block and set the colour via setBlending. Click on the create Project->Image Map Builder Navigation Button in TGB: Select the image we supplied. And set the mode to CELL. Then you need to set the cell width and height to 16, you should have 8 tiles. Name it blocksImageMap. |
|
[edit] Building BlocksHow do we create the seven shapes shown in the image above including all 4 orientations (as the shapes can be rotated in 90deg steps)? If you look at each of the seven shapes, they're all made up from four connected blocks in a grid like pattern. We can thus represent any of the seven shapes using a 2 dimentional array containing a 1 if we have a block and a 0 if not. NOTE: Although a 4x3 array would provide enough room to define each shape, we'll even things out and use a 4x4 array as this will later help with rotation For example, the first three shapes will be arrays containing the following: 0,1,0,0 0,0,0,0 0,0,0,0 0,1,0,0 1,1,1,0 0,1,1,0 0,1,0,0 0,1,0,0 0,1,1,0 0,1,0,0 0,0,0,0 0,0,0,0 Rather than just storing a 1 or 0, if we instead store a number in the range 1 to 7 we can use this as a frame index into our shapes image map, and thus declare what colour each shape is going to be up front. NOTE: Or we could randomly assign a colour to the shape when we drop it into the game "well". It's up to you, we're using frame indexing for this tutorial. We'll create a new file gameScripts/shapes.cs to define a shape class:
function ShapeTemplate::onAdd(%this)
{ // This is where our shape is added to the scene.
if(!isObject(shapeGroup))
new SimSet(shapeGroup);
for(%x = 0; %x < $GAME::SHAPES::SIZEX; %x++)
{
for(%y = 0; %y < $GAME::SHAPES::SIZEY; %y++)
{
%this.block[%x, %y] = getword(%this.getTileType(%x, %y), 2); // Frame index =)
}
}
shapeGroup.add(%this);
// %this.setEnabled(false);
%this.setEnabled(true);
// While making our game, we want our shapes to be visible--later when our game is complete,
// we will use the setEnabled(false); statement
}
This means that whenever you make a tile-layer, and set its class to ShapeTemplate, it will be added to the available dropping blocks! Make a few 4x4 tile layers, draw the shapes, drop them onto the level and set their class to ShapeTemplate.
At the top of gameScripts/game.cs beneath the comment header section we'll add an couple of variables that hold the max size of the shapes, just in case we ever decide to break tradition and use 5x5 or another shape size. Cut and paste the following into game.cs: $GAME::SHAPES::SIZEX = 4; $GAME::SHAPES::SIZEY = 4; NOTE: I tend to group variables with a GAME prefix along with sub-grouping. This is a matter or personal taste and not required. I also tend to use all upper case to indicate that the variable is a constant and shouldn't be changed at run time. There is nothing stopping you using variables such as $SIZEX and $SIZEY instead (assuming you find/replace).
NOTE: If you haven't yet studied the TileMap Tutorial, I highly suggest you do so now as the following section does not provide details on use of the tool. Now we need to make some shapes! Create a lot of 4x4 TileLayers in the Map editor and save them. You may need to restart TGB for them to appear. Drag them onto the scene and set their class to ShapeTemplate. Each shape should have it's own layer, and should be saved as "shapename".lyr for proper loading during our game setup. Also in initializeProject() in Tetris/main.cs underneath exec("./gameScripts/game.cs"); add an exec for shapes.cs:
Exec("./gameScripts/shapes.cs");
|
|
[edit] Game WellThe majority of the screen is occupied by the "well" into which the various Tetris pieces fall. The main gameplay “well†we'll give the arbitary dimensions of 12 by 18 blocks. Pieces will fall from the top of the “well†to the bottom one line at a time. To create the well, enter the map-editor from the project menu. Click File, New TileMap and enter these settings: Select the blocksImageMap in the tile-browser and draw one wall tile in the top-left corner. Shift click on it, check "Collision Active?", and set the custom data box to say WALL. You can then Alt-Click the tile to create a brush, with identical settings. Then draw the shape of the well (U). Save it as a tile LAYER in data/tilemaps/tetris.LYR . Restart TGB. After this is done, you should see the tilemap on the sidebar. Drag it onto the scene and position it as you like. Set the name of the TileMap to WellTileMap.
|
|
[edit] Current ShapeIf you think about the gameplay of Tetris, the game involves a single shape (the current shape) falling from the top of the well to the bottom in addition to showing the next shape. If you think ahead a little you'll realize that once the current shape stops moving due to reaching the bottom of the well or been impeeded by other blocks, we'll want to make the NextShape the new CurrentShape. E.G. we'll need to copy the NextShape to the CurrentShape as well as copy at random one of the 7 base shapes to the NextShape. So with that in mind we'll create a CopyFrom function. We will use a ScriptObject to encase the current and next shapes. I don't want to get into namespaces in this tutorial, refer to TorqueScript namespaces section for an overview NOTE: Cut and paste the following section of code into your shapes.cs file.
$GAME::CurrentShape = new ScriptObject() { class = "SHAPE"; };
$GAME::NextShape = new ScriptObject() { class = "SHAPE"; };
function Shape::CopyFrom(%this,%source )
{
// copy specified shape from the Shapes array into the $CurrentShape array.
for (%x=0;%x<$GAME::SHAPES::SIZEX;%x++)
for (%y=0;%y<$GAME::SHAPES::SIZEY;%y++)
%this.block[%x,%y] = %source.block[%x,%y];
%this.position = %source.position;
}
Finally we need to give the current and next shapes a value, we can do this in startGame by copying a random shape - assuming you've defined all 7 shapes :) NOTE: Copy the following section of code and paste into your game.cs, into the startGame method.
$GAME::CurrentShape.CopyFrom( shapeGroup.getObject(getRandom(0,shapeGroup.getCount() -1)) );
$GAME::CurrentShape.position = WellTileMap.getTileCountX()/2 SPC -3;
$GAME::NextShape.CopyFrom( shapeGroup.getObject(getRandom(0,shapeGroup.getCount() -1)) );
$GAME::NextShape.position = "0 0";
We offset the position by -3 so that the bottom row of the shape (if it has any blocks) should be visible, failing that it will only be a few movements before the shape comes into view. Position defines the TopLeft of the shape. A variation on this is to examine the currentshape array and find the first row that contains a block and position accordingly. NOTE: You could scan through the shape block array to find the bottom most block of the shape and offset the position to ensure that this block is visible at the top of the well. If you choose to do so, you'll also need to modify the game over logic to take this into account. If you run the game and type in the console "$GAME::CurrentShape.dump();" you should see the new CopyFrom function that we've added along with all the other functions you can call on a ScriptObject. If you create a scriptobject without setting the class type you'll see if doesn't have the method. Thus the CopyFrom method is only supported/usable on ScriptObjects that we use to represent Shapes. NOTE: Namespaces can be very confusing at first, so I suggest you spend a little time reading up on the various uses on TDN as well as the numerous forum threads. Once you understand them however you find numerous ways to use then to your advantage.
|
|
[edit] Drawing ShapesAs we're using tilemaps we need to set and unset the cells of the tile map as the current shape falls down line by line. We'll extend our shape script objects adding the ability to Draw and Erase themselves from the tilemap. Obviously the Shapes need to know which tilemap they're drawing to, so we'll pass this in as a parameter. Currently we only have one tilemap, however later we will be adding a second to display the NextShape in. As with the Shape::CopyFrom method, this new method should be put in gameScripts/Shapes.cs NOTE: Rather than passing the tilelayer to draw to each time we call draw, you could have the shape store the layer it will be drawing to, then provide a means to change the layer should we choose to have it draw elsewhere.
function Shape::Draw(%this, %map)
{
%curX = getWord(%this.position,0);
%curY = getWord(%this.position,1);
// get a reference to the tilemap layer
for (%x=0;%x<$GAME::SHAPES::SIZEX;%x++)
for (%y=0;%y<$GAME::SHAPES::SIZEY;%y++)
if(%this.block[%x,%y] != 0)
%map.setStaticTile(%x + %curX SPC %y + %curY, blocksImageMap,
%this.block[%x,%y]);
}
If we're drawing a shape we'll also need the ability to erase it, so add the following function as well.
function Shape::Erase(%this, %map)
{
%curX = getWord(%this.position,0);
%curY = getWord(%this.position,1);
// get a reference to the tilemap layer
for (%x=0;%x<$GAME::SHAPES::SIZEX;%x++)
for (%y=0;%y<$GAME::SHAPES::SIZEY;%y++)
if(%this.block[%x,%y] != 0)
%map.clearTile(%x + %curX SPC %y + %curY);
}
|
|
[edit] Adding GravityWe want to erase the current shape and move it one line down every few seconds. To do this we'll hook into the TGB's main loop through the onUpdateScene callback. NOTE: as this callback is called as part of the mainloop we need to be careful not to do anything too intensive within it. The engine will call the function onUpdateScene for each scene graph, so in order for us to provide an implementation of this function we simply extend the sceneWindow2D object by adding the onUpdateScene function within the namespace "sceneWindow2D". The function has a single parameter %this which is the ID for the scenegraph that is currently been updated. We only have one scene graph so we don't really need to worry about this (pardon the pun) If you type into the console "sceneWindow2D.dump();" before adding the function and after adding it, you'll see that the function is now a member of our scenegraph. Since we only want pieces to move every few seconds, we need to get the current game time and compare this to the last time we moved the current peice. If sufficient time has passed to perform an update, we need to perform a few tasks.
A number of these steps such as collision detection, winning lines, end game checking we'll deal with in more detail later in the tutorial. Copy and paste the following into your game.cs file.
function sceneWindow2D::onUpdateScene(%this)
{
// current scene time in miliseconds
%curTime = %this.getSceneTime()*1000;
// time for a new update?
if(%curTime - $SCENE::LastUpdateTime > $GAME::SHAPES::MOVESPEED)
{
if( isValidMove("0 1", $GAME::CurrentShape) )
{
$GAME::CurrentShape.Erase(WellTileMap);
$GAME::CurrentShape.Move(0,1);
$GAME::CurrentShape.Draw(WellTileMap);
}
// else
// test for complete lines
// test for end of game
// move to next shape
$SCENE::LastUpdateTime = %curTime;
}
}
// is the shape allowed to be at %position? Returns true if the shape will
// not collide with the arena walls or another block
function isValidMove( %delta, %shape )
{
// We need to add a test in here to check for collisions with
// other blocks at the base of the game well, however for now we'll just
// make a simple test for the walls and return to this later in the tutorial.
%newX = getWord(%delta,0) + getWord(%shape.position,0);
%newY = getWord(%delta,1) + getWord(%shape.position,1);
return ( %newY+$GAME::SHAPES::SIZEY < WellTileMap.getTileCountY() );
}
We will extend the Shape object further by adding Move support in gameScripts/shapes.cs
function Shape::Move(%this, %dx, %dy)
{
%curX = getWord(%this.position,0);
%curY = getWord(%this.position,1);
%this.position = %curX+%dx SPC %curY+%dy;
}
We also need to declare these constants at the top of the gameScripts/game.cs file $GAME::SHAPES::MOVESPEED = 1000; // How fast stuff falls. $GAME::MAP::MOVING_TILE = "MOVING"; // The custom tile data for a moving tile. $GAME::MAP::WALL_TILE = "WALL"; // Custom for walls $GAME::MAP::FIXED_TILE = "FIXED"; // Custom for blocks that have reached the bottom. $GAME::MAP::WALLIMAGEID = 0; // Wall ImageID At this stage you should now have a single shape spawn at the top of the game well (just off screen) and move one line at a time to the bottom of the well, stopping when it hits the bottom wall. The isValidMove is crude at this stage and works only with the I shape, it also doesn't check the left/right bounds of the well, but for now it will do. This is a start, but we have more to do movement wise. The next few sections of the tutorial will cover movement and rotation in a little more depth. Then we'll return our attention to the onUpdateScene function to add in detection for complete lines, game over as well as spawning the next shape and finally updating the isValidMove function to detect collisions with other blocks rather than just the base of the game well.
|
|
[edit] Lateral MovementIf you've read the basic shooter tutorial as recommended at the beginning of this tutorial, you should have seen ActionMaps in use for controlling the player's spaceship. We'll setup an actionmap to support moving the current shape left or right as well as accelerating the speed it falls downward. Add the following into setupKeybinds() in gameScripts/main.cs. moveMap.bind( keyboard, "left", moveLeft, "Move Left"); moveMap.bind( keyboard, "right", moveRight, "Move Right"); moveMap.bind( keyboard, "down", moveDown, "Move Down"); This automatically adds entries to the Options dialog. When the player presses the left or right cursor key we update the current shapes X coordinate, where X is a cell within the tile map rather than a pixel location. We should only allow a move if the position we're moving into is not occupied by any other blocks or walls. We'll modify the isValidMove function to test for block collisions rather than just collisions against the bottom of the well later on in the tutorial, for now just refrain from hitting left more than a couple of times :) The Down arrow is setup to allow the player to increase the effects of gravity causing the shape to fall to the bottom of the well much faster. We'll modify the onUpdateScene function where gravity is applied to use a $Game::SpeedModifier to reduce the interval between game updates, setting the modifier as the down key is pressed and released. Add to the top of gameScripts/game.cs $GAME::SpeedModifier = 1; then update the onUpdateScene method to // current scene time in miliseconds %curTime = %this.getSceneTime()*1000; // time for a new update? if(%curTime - $SCENE::LastUpdateTime > $GAME::SHAPES::MOVESPEED / $GAME::SpeedModifier) So we have the ability to modify the speed at which the shapes fall, but it won't be having much impact if we always leave it set at its default value of "1". We'll resolve this by creating the key press handlers we setup in the players actionmap. The move functions will be passed a %val parameter that is true when the key is pressed/down and false when the key is released. We'll use this to speed up the effects of gravity and reduce them back to normal again.
function moveLeft(%val)
{
if(%val && isValidMove("-1 0", $GAME::CurrentShape))
{
$GAME::CurrentShape.Erase(WellTileMap);
$GAME::CurrentShape.Move("-1 0");
$GAME::CurrentShape.Draw(WellTileMap);
}
}
function moveRight(%val)
{
if(%val && isValidMove("1 0", $GAME::CurrentShape))
{
$GAME::CurrentShape.Erase(WellTileMap);
$GAME::CurrentShape.Move("1 0");
$GAME::CurrentShape.Draw(WellTileMap);
}
}
function moveDown(%val)
{
if (%val)
$GAME::SpeedModifier = 8;
else
$GAME::SpeedModifier = 1;
}
|
|
[edit] RotationSo we have our shapes, but how do we go about supporting rotation? Rotation may not be as simple as you first imagine. You could just rotate all shapes around the center of the 4x4 array, but this can produce weird looking rotations for several shapes. For example a 180 degree rotation of the I shape would be expected to look exactly the same as not rotating it at all. Likewise the shape at 90 and 270 degrees should look the same. However, the shape ends up moving laterally eg. 0,1,0,0 rotate twice gives 0,0,1,0 0,1,0,0 0,0,1,0 0,1,0,0 0,0,1,0 0,1,0,0 0,0,1,0 We could use a 5x5 array, but if you do so, you'll notice that now the 2x2 square shape exhibits problems with rotation, in fact this shape should look the same in all 4 rotations. How do we solve this problem? Well some versions of tetris don't solve it, it is a part of the game. I'm not certain how the original tetris game did its rotation, however for the sake of keeping this tutorial simple, we'll pretend it does it this way :P
x' = 3-y y' = x
NOTE: If you really don't like this, you can always hard code the definition of the shape in each of its 4 rotations. Since we only have 7 shapes with 4 rotations each we can go for the brute force approach and hard code each of the rotations 28 in total. We could do this by extending the scriptobjects block array to a 3rd dimention. The index of which will be 0 through 3 representing rotations of 0,90,180 and 270 degrees respectivly Athough there is nothing wrong with this approach and in some cases it might be preferred, it might become tedious should you ever use more then a handful of shapes. You could also come up with an algorithm to allow rotation around a given pivot point. The keys [ and ] will be bound to our rotation function as well as the UP arrow for lazy players. The only other things left to do after rotation is check the new shape orientation covers a legal area of the play area, eg it doesn't overlap any existing blocks. If everything checks out, we can erase the old shape and redraw the new one. For this reason we'll perform the initial rotation on a COPY of the CurrentShape rather than the original. This has the benefit that illegal rotations don't require us to undo the rotation in any way. We'll create a global object to temporarily hold the rotated shape. We use a global to save creating/deleting a script object every time the player rotates the current shape. Place the following in your main.cs--the moveMap.bindCmd's in the setupKeybinds() section, and the rest in the game.cs file after your other movement functions.
moveMap.bind( keyboard, "[", rotateClockwise, "Rotate Block Clockwise");
moveMap.bind( keyboard, "]", rotateCounterClockwise, "Rotate Block Counter-Clockwise");
$GAME::TempRotationShape = new ScriptObject() { class = "SHAPE"; };
function rotateClockwise(%val)
{
if(%val)
{
// Create a new rotated shape
%shape = $GAME::TempRotationShape;
for (%x=0;%x<$GAME::SHAPES::SIZEX;%x++)
for (%y=0;%y<$GAME::SHAPES::SIZEY;%y++)
%shape.block[%x,%y] = $GAME::CurrentShape.block[3-%y,%x];
%shape.position = $GAME::CurrentShape.position;
// is it in a valid location?
if (isValidMove("0 0", %shape))
{
// Erase current shape
$GAME::CurrentShape.Erase(WellTileMap);
// Make rotated shape the current shape
$GAME::CurrentShape.CopyFrom( %shape );
// Redraw
$GAME::CurrentShape.Draw(WellTileMap);
}
}
}
function rotateCounterClockwise(%val)
{
if(%val)
{
%shape = $GAME::TempRotationShape;
for (%x=0;%x<$GAME::SHAPES::SIZEX;%x++)
for (%y=0;%y<$GAME::SHAPES::SIZEY;%y++)
%shape.block[%x,%y] = $GAME::CurrentShape.block[%y,3-%x];
%shape.position = $GAME::CurrentShape.position;
// is it in a valid location?
if (isValidMove("0 0", %shape))
{
// Erase current shape
$GAME::CurrentShape.Erase(WellTileMap);
// Make rotated shape the current shape
$GAME::CurrentShape.CopyFrom( %shape );
// Redraw
$GAME::CurrentShape.Draw(WellTileMap);
}
}
}
|
|
[edit] Collision DetectionIf you move the current shape left enough times you'll notice that it passes through the wall at the edge of the well and probably erased it to boot. We need to add in block collision detection. Which means updating isValidMove. The current shape is defined as a 4x4 array with the shapes current position being the 0,0 element. This means certain shapes may have a current position that is inside the left or right wall whilst none of its blocks are, since the first column could be empty, this is certainly the case for the I shape. Thus our collision detection needs to run through the 4x4 array and check for any element that is none zero, ie contains a block and whether the current position offset by the current index into the block array is an occupied tile in the well map. As a further complication, we have to prevent the collision check detecting our new position as a collision against our old location which we still occupy in the tilemap. We have a few possible solutions.
We'll take the custom data approach, as with everything feel free to come up with a more elegant solution. CustomData is not used by TGB in any way other than to allow us to assosiate arbitary data with a specific tile. We'll set all walls and fixed blocks to "wall" and "fixed" respectivly. We'll also set the current block to "moving" and ensure blank tiles are reset to "". NOTE: You could store additional information and use getword/setword to access the individual bits of information. The isValidMove function will take a %delta parameter which should contain the direction you wish to move the shape for example "0 1" would indicate a move down since we'll be increasing X by 0 and Y by 1. Whilst "3 -1" would indicate that the shape wants to move 3 units left and 1 unit upwards, a strange request, but you get the idea. It also takes %shape. This is expected to be a scriptobject, not just any old script object either, but one that we've used to store shape data in, eg the block array and position. This may be CurrentShape, NextShape or the object used to temporarily store the shape during rotation.
function isValidMove( %delta, %shape )
{
%newX = getWord(%delta,0) + getWord(%shape.position,0);
%newY = getWord(%delta,1) + getWord(%shape.position,1);
%map = WellTileMap;
for(%x = 0; %x < $GAME::SHAPES::SIZEX; %x++)
for(%y = 0; %y < $GAME::SHAPES::SIZEX; %y++)
if (%shape.block[%x,%y] != 0)
{
// has the block left the confines of the arena? Only really
// possible without a collision if you use broken shapes or
// move left and rotate very quickly at the start :P
if ( %newX+%x <= 0 || %newX + %x >= WellTileMap.getTileCountX()-1)
return false;
// does the block collide with an existing block?
%data = %map.getTileCustomData(%newX+%x SPC %newY+%y);
if (%data !$= "" && %data !$= $GAME::MAP::MOVING_TILE)
return false;
}
return true;
}
Since we're now using custom data we should really make sure we set all walls to "wall" and moving shapes to "moving". Note I've added each of the three strings as defines at the top of gameScripts/game.cs rather than using the string "wall" directly. and the Shape::Draw method changes to set the tiles as moving to:
if(%this.block[%x,%y] != 0)
{
%map.setStaticTile(%x + %curX SPC %y + %curY, blocksImageMap,
%this.block[%x,%y]);
%map.setTileCustomData(%x + %curX SPC %y + %curY, $GAME::MAP::MOVING_TILE);
}
|
|
[edit] Next ShapeBeing able to move and rotate the current piece as it drops down the well is only fun for so long. Its time to get the nextShape up and running. We've already generated the next shape at the same time as we created the initial currentShape. We need to add code to make the NextShape the currentShape and generate a new NextShape. We'll also added in stubs for CheckGameOver and CheckCompleteLine methods which we'll complete later. So when do we need to change to the next play piece? When the current peice cannot move any further down the well, eg its had a collision which isValidMove has detected. When isValidMove returns false indicating a collision the action we take depends upon where where in the game logic we are when the collision occurs.
OnUpdateScene is where we apply gravity and thus contains the isValidMove test that upon failure should result in a new shape coming into play. We can update it as follows:
if( isValidMove("0 1", $GAME::CurrentShape) )
{
$GAME::CurrentShape.Erase(WellTileMap);
$GAME::CurrentShape.Move(0,1);
$GAME::CurrentShape.Draw(WellTileMap);
}
else
{
// test for complete lines
CheckCompleteLine();
// test for end of game
CheckEndGame();
// move to next shape
ChangeToNextShape();
}
Once a shape reaches the bottom of the well, or stops moving due to colliding with another block, we need to turn it from a moving shape into a "fixed". We can do this by redrawing the shape with different custom data.
function Shape::DrawAsFixed(%this, %map)
{
%curX = getWord(%this.position,0);
%curY = getWord(%this.position,1);
// get a reference to the tilemap layer
for (%x=0;%x<$GAME::SHAPES::SIZEX;%x++)
for (%y=0;%y<$GAME::SHAPES::SIZEY;%y++)
if(%this.block[%x,%y] != 0)
{
%map.setStaticTile(%x + %curX SPC %y + %curY, blocksImageMap,
%this.block[%x,%y]);
%map.setTileCustomData(%x + %curX SPC %y + %curY, $GAME::MAP::FIXED_TILE);
}
}
Once we've changed the shape into a fixed shape, we can change the current shape to the NextShape and reset its position. Finally we generate a new NextShape.
function ChangeToNextShape()
{
$GAME::CurrentShape.DrawAsFixed();
$GAME::CurrentShape.CopyFrom( $GAME::NextShape );
// Reset position of shape to top of game well
$GAME::CurrentShape.position = $GAME::SHAPES::STARTINGPOSITION;
// Generate a new random NEXT shape
$GAME::NextShape.CopyFrom( shapeGroup.getObject(getRandom(0,shapeGroup.getCount())) );
$GAME::NextShape.position = "0 0";
}
Update (monkeyboy): Changing the code for generating a new random next block from $GAME::NextShape.CopyFrom(shapeGroup.getObject(getRandom(0,shapeGroup.getCount())));to $GAME::NextShape.CopyFrom(shapeGroup.getObject(getRandom(0,shapeGroup.getCount() - 1)));should present the desired results. When using the original code, a block would not get assigned to the NextShape most likely because the code was trying to retrieve an object out of bounds of the SimSet. I've also added this directly after sceneWindow2D.loadlevel, that way it works out the start position from the level just loaded: $GAME::SHAPES::STARTINGPOSITION = WellTileMap.getTileCountX()/2 SPC -3; Now its starting to look more like tetris. We're still missing one more part of the Next peice puzzle, that of displaying to the player what the next shape actually is. This is actually simple given the framework above, we simply add a 4x4 tilemap to the top right of the GUI then use the existing shapes Draw function passing in the layer from our new tilemap. Create it via the Map-Editor the same as before. Continuing with our changes to startGame just below where we draw the CurrentShape, we'll also draw the next shape. $GAME::CurrentShape.Draw(WellTileMap); $GAME::NextShape.Draw(NextShapeTileMap); Next up we create the tilemap. As with the game "well" tilemap we set the position of the map, its size and the layer it will be rendered into. Since we generate new shapes via the ChangeToNextShape function, we'll update it to redraw the next shape in the NextShapeTileMap
function ChangeToNextShape()
{
$GAME::CurrentShape.DrawAsFixed(WellTileMap);
$GAME::CurrentShape.CopyFrom( $GAME::NextShape );
// Reset position of shape to top of game well
$GAME::CurrentShape.position = $GAME::SHAPES::STARTINGPOSITION;
// Generate a new random NEXT shape
$GAME::NextShape.CopyFrom(shapeGroup.getObject(getRandom(0,shapeGroup.getCount() - 1)));
NextShapeTileMap.clearLayer();
$GAME::NextShape.position = "0 0"; //edited
$GAME::NextShape.Draw(NextShapeTileMap);
}
|
|
[edit] Detecting Complete LinesAt this stage, shapes should be falling one at a time to the bottom of the well, or as close to it as they can get. Some lines will have gaps others will have every tile of the "well" filled with a block from one shape or another. It is these complete lines that we will deal with next. In onUpdateScene add a call to CheckCompleteLine(); under the "test for complete lines" comment. The way the onUpdateScene is setup, the completeLine check will only be called once the current shape has finished moving. This means that in the best case (with the I shape) the player will be able to complete 4 lines at the same time. We know the position the CurrentShape was at when the collision occured so can we simply iterate from x is 1 to well width -1 (1 and -1 to account for the two walls!) for each line the current shape could be on. If during the X iteration we find a tile that isn't a fixed tile nor moving tile, then the line is not complete. Any line that is complete we add to a %completeList. Once we've finished all 4 possible lines for completion, we can award points to the player and erase the complete lines. Scoring will be dealt with later, for now we will just add a call to a dummy reward function.
function CheckCompleteLine()
{
%curY = getWord($GAME::CurrentShape.position,1);
%completeList = "";
%well = WellTileMap;
for (%y=0; %y<4; %y++)
{
%FullLine = true;
for (%x=1; %x < WellTileMap.getTileCountX()-1; %x++)
{
%data = %well.getTileCustomData(%x SPC %curY+%y);
if ( %data !$= $GAME::MAP::FIXED_TILE && %data !$= $GAME::MAP::MOVING_TILE)
{
%FullLine = false;
break;
}
}
if (%FullLine)
{
// add line to completed line list for later removal
%completeList = setWord(%completeList, getWordCount(%completeList), %curY + %y);
}
}
// if we've completed any lines we need to award points
if (getWordCount(%completeList) > 0)
{
RewardCompleteLines(getWordCount(%completeList));
// delete the lines that have been completed
EraseLines(%completeList);
}
}
Assuming we've completed any lines, we need to remove them and drop the lines above down by one line. There are a few different ways to do this some more optimal than others. The %completeList will contain the ID of between 1 and 4 lines from the WellTileMap that have been complete. The list is ordered in such a way that the smallest Y line (nearest to the top of the well) if first and the line nearest the bottom of the well is last. The list may not be continous, since out of the 4 lines our moving shape may have completed lines 4 and 2 with 3 and 1 still having gaps. Before we do anything we can run through all tiles on each of the lines in %completeList and clear them. Now we have to deal with moving the lines above down. One simple method is to start with the last line in the %completeList and move every line above it down by 1. Then get the next line from %completeList and move every line above it down by one. This isn't really optimal though as we could end up moving many of the lines up to 4 times in a row, each time down by 1 line. Lets take an example to illustrate the problem and solution. Assume the game well is 20 lines deep and 10 lines wide (ignore walls) we've just finished moving a I shape (4 blocks in a vertical line) to the bottom of the well, so that it occupies lines 20,19,18, and 17. In doing so we've completed the lines that block 1 and 4 of the shape occupy, that is lines 17 and 20 in our tilemap. So we erase each line in the completeList eg 20 and 17. We now need to shift everything down. If we start with the last line in completeList, line 20, we move all lines between this and the next line in completeList down by 1 position. In this case line 19 and 18. We're now on the next line in the completeList, line 17. From here on we move all lines above it down by 2. If the I shape completed all 4 lines in one go, the principle would be the same. We'd start at line 20 and move all lines between that and the next in the list down by 1, which is 0 lines as the list is 17,18,19,20 and there are no lines between 19 and 20. We thus end up on line 17 having moved no lines downwards ready to move the rest of the lines down by 4. Hopefully the following code will clarify this further:
function EraseLines(%completeList)
{
%toErase = %completeList;
%well = WellTileMap;
// starting with the bottom complete line and working backwards
// we'll move all lines down by N place. Each time we reach a complete lines
// we move all subsequent lines down by N+1
%dropBy = 0;
%curLine = getWord(%toErase,getWordCount(%toErase));
while( %curLine >= 0 )
{
// if we're on a line that has been completed increase dropBy and erase it
if (getWord(%toErase, getWordCount(%toErase)) !$= "")
{
// delete line
for (%x = 1; %x < WellTileMap.getTileCountX()-1; %x++)
%well.clearTile(%x SPC %curLine);
// remove line from toErase list
%toErase = setWord(%toErase, "", getWordCount(%toErase));
// increase drop by
%dropBy++;
}
else
{
// shift the current line down by %dropBy number of lines
for (%x = 1; %x < WellTileMap.getTileCountX()-1; %x++)
{
%tileColor = getWord(%well.getTileType(%x SPC %y), 2);
if(%tileColor !$= "")
{
%well.setStaticTile(%x SPC %y + %delCount, blocksImageMap, %tileColor);
%well.setTileCustomData(%x SPC %y + %delCount, $GAME::MAP::FIXED_TILE);
// erase current tile
%well.clearTile(%x SPC %y);
}
}
}
%curLine--;
}
}
NOTE: This method of line erase/dropping can leave floating blocks. An alternative method uses a flood fill algorithm to ensure floating blocks drop down until they're resting on the bottom of the well or another block. Refer to http://en.wikipedia.org/wiki/Tetris for information.
function EraseLines(%completeList)
{
//modified by Tetraweb
%toErase = %completeList;
%well = WellTileMap;
// starting with the bottom complete line and working backwards
// we'll move all lines down by N place. Each time we reach a complete lines
// we move all subsequent lines down by N+1
%dropBy = 0;
%curLine = getWord(%toErase,getWordCount(%toErase)-1);
while( %curLine >= 0 )
{
// if we're on a line that has been completed increase dropBy and erase it
if (getWord(%toErase, getWordCount(%toErase)-1) !$= "")
{
// delete line
for (%x = 1; %x < WellTileMap.getTileCountX()-1; %x++)
%well.clearTile(%x SPC %curLine);
// remove line from toErase list
%toErase = setWord(%toErase, getWordCount(%toErase)-1, "");
// increase drop by
%dropBy++;
}
else
{
// shift the current line down by %dropBy number of lines
for (%x = 1; %x < WellTileMap.getTileCountX()-1; %x++)
{
%tileColor = getWord(%well.getTileType(%x , %curLine), 2);
if(%tileColor !$= "")
{
%well.setStaticTile(%x SPC %curLine + %dropBy, blocksImageMap, %tileColor);
%well.setTileCustomData(%x SPC %curLine + %dropBy, $GAME::MAP::FIXED_TILE);
// erase current tile
%well.clearTile(%x SPC %curLine);
}
}
}
%curLine--;
}
}
there is still one bug for erasing line, add $GAME::CurrentShape.DrawAsFixed(WellTileMap) before CheckCompleteLine(), and delete $GAME::CurrentShape.DrawAsFixed(WellTileMap) from ChangeToNextShape() --weixin shen
|
|
[edit] ScoringThere are a number of variations on how to score in Tetris, we'll keep it simple, you can always come up with your own variation. As we already know the player can complete up to 4 lines in a single go, so we'll reward multi-line scoring using the same values that the gameboy used:
Lets keep it ultra simple and use a linear scale, where by the level increases every 20 complete lines. To keep track of the players scores we'll also add 3 new game variables. Add the following near the top of gameScripts/game.cs // Multiplier for multiple line completion $GAME::Score::LineMultiplier[0] = 40; $GAME::Score::LineMultiplier[1] = 100; $GAME::Score::LineMultiplier[2] = 300; $GAME::Score::LineMultiplier[3] = 1200; // Track current players score $GAME::Score::Points = 0; $GAME::Score::Level = 1; $GAME::Score::Lines = 0; NOTE: If you later add a main menu or a means for the player to start a new game you must reset these 3 score variable before the game begins Lets fill in the RewardCompleteLines stub we created early and give the player some points.
function RewardCompleteLines(%lines)
{
// award points
$GAME::Score::Points += $GAME::Score::Level * $Game::Score::LineMultiplier[%lines-1];
// update completed line count
$GAME::Score::Lines += %lines;
// have we completed another 20 lines? Next level time :)
if ($GAME::Score::Lines % $GAME::SCORE::LINESPERLEVEL == 0)
{
$GAME::Score::Level++;
}
}
The player can now progress from level to level, but what exactly happens when the level increases? Nothing yet, we'll deal with this a little later. Before that lets get the score displayed on screen.
|
|
[edit] Building a Score GUIHow do we display the score? Well for now we'll keep things simple, and add 3 Text gui controls to the main gui, having them monitor our score variables. NOTE: You can improve the look of this by creating your own custom font. Another alternative is to create image map frames for the digits 0 through 9 then either as a custom TGB class or via script render the correct frame based on the score. You can find examples of this method on the forums, at least until some kind soul updates this tutorial with a link to it or even better adds it to this wiki as a tutorial. First off we'll create a new profile for use by these text controls. In brief profiles allow us to setup a standard set of parameters such as font size, type, colour etc which can then be shared amongst multiple controls. They also save you lots of time when you decide to tweak the look of your game allowing you to change the font size of several related controls in one swoop. This is covered in more detail elsewhere in the wiki. (add link to profiles wiki page – ed) Create a new file gui/customProfiles.cs and exec it in initialiseProject just before the exec("~/gui/mainScreenGui.gui"); line. We'll add a new profile for the score GUI controls based on the existing Text profile to this file.
if(!isObject(GuiScoreProfile)) new GuiControlProfile (GuiScoreProfile : GuiTextProfile)
{
fontType = "Arial Bold";
fontSize = "26";
fontColor = "239 249 57";
};
Either via the GUI editor or by manually editing the mainScreenGui.gui file, add 3 new GuiTextCtrls, call these LevelLbl, ScoreLbl and LinesLbl. Also set each of their profiles to GuiScoreProfile. If you don't see a GuiScoreProfile, check you've exec'd the customprofiles.cs file. Don't forget to "apply" your name changes. You're mainScreenGui.gui should now be similar to this:
//--- OBJECT WRITE BEGIN ---
new GuiChunkedBitmapCtrl(mainScreenGui) {
profile = "GuiContentProfile";
horizSizing = "width";
vertSizing = "height";
position = "0 0";
extent = "640 480";
minExtent = "8 8";
visible = "1";
bitmap = "./images/gamearena";
useVariable = "0";
tile = "0";
new t2dSceneWindow(sceneWindow2D) {
profile = "GuiContentProfile";
horizSizing = "width";
vertSizing = "height";
position = "0 0";
extent = "640 480";
minExtent = "8 8";
visible = "1";
lockMouse = "0";
};
new GuiTextCtrl(LevelLbl) {
profile = "GuiScoreProfile";
horizSizing = "relative";
vertSizing = "relative";
position = "490 285";
extent = "50 30";
minExtent = "50 30";
visible = "1";
text = "0";
maxLength = "255";
variable = "$GAME::Score::Level";
};
new GuiTextCtrl(ScoreLbl) {
profile = "GuiScoreProfile";
horizSizing = "relative";
vertSizing = "relative";
position = "490 357";
extent = "50 30";
minExtent = "50 30";
visible = "1";
text = "0";
maxLength = "255";
variable = "$GAME::Score::Points";
};
new GuiTextCtrl(LinesLbl) {
profile = "GuiScoreProfile";
horizSizing = "relative";
vertSizing = "relative";
position = "490 430";
extent = "50 30";
minExtent = "50 30";
visible = "1";
text = "0";
maxLength = "255";
variable = "$GAME::Score::Lines";
};
};
//--- OBJECT WRITE END ---
Although we've used a shortcut and had the 3 gui controls monitor the 3 scoring variables, you may wish to set the controls text manually allowing greater formatting control. For example you may wish level to always be 3 digits "001". You could also accomplish this by editing the GuiTextCtrl to allow the "text" property to specify a formatting type. However these no point doing so if you later plan to replace the GUI controls with TGB images. Run the game and you'll see its starting to come together nicely. We're still missing two important aspects though, the gamespeed isn't changing as the level increases and the game isn't ending.
|
|
[edit] Level DifficultyEvery 20 complete lines we've increased the level. This should have an impact on the game speed so that blocks fall quicker and quicker as the level continues to rise. How you choose to do this depends upon how much effect you think going up each level should have, yet again we're taking the simple option. Update onupdateScene's time for new update check to be if(%curTime - $SCENE::LastUpdateTime > $GAME::SHAPES::MOVESPEED / ($GAME::SCORE::Level/3) / $GAME::SpeedModifier) This test is one of the reasons that level starts off at "1" rather than 0. The other was for the scoring multiplier. Since this affects the initial game speed considerably, we'll reduce the starting MOVESPEED down to 500 (change its definition at the top of gameScripts/game.cs to 500). Which results in moving one line down every 1.5seconds for level 1, every 750ms for level2, 500 for lvl 3 and so on.
|
|
[edit] Game Over Man!We're almost there, we just need to add support for losing the game. Normally if your block pokes out the top of the game well and has had its fall impeded by a fixed block the game ends. Lets add support for this by filling in the CheckGameOver stub. Testing for game over is really quite straightforward. We loop over the current shape to find the bottom most row out of its 4x4 array that contains a block (since the bottom row could be empty on some shapes). Then we test if this block is on the playing well or not. As this test is only called when the shape can no longer move down, if its not on the playing well, its game over.
function CheckGameOver( )
{
%shape = $Game::CurrentShape;
%newX = getWord(%shape.position,0);
%newY = getWord(%shape.position,1);
%top = -1;
// find topmost block that defines the current shape
for(%x = 0; %x < $GAME::SHAPES::SIZEX; %x++)
{
for(%y = 0; %y < $GAME::SHAPES::SIZEX; %y++)
if (%shape.block[%x,%y] != 0)
{
%top = %y;
break;
}
if (%top != -1)
break;
}
// is the top of the block not on the playing field?
if (%newY+%top < 0)
{
$GAME::GameOver = true;
playerMap.Pop();
displayGameOver();
}
}
As we have no main menu to return to or the ability to start a new game (thats your homework ;-) we'll simply set a $GAME::GameOver flag to true and test this flag in onUpdateScene as well as popping the players input map. You could push a new map with key settings to start a new game, or return to the main menu and let the player select new game, or display high scores or pretty much anything else you fancy. The game over flag should be defined/initialised to false at the top of game.cs where we've defined everything else.
if ($GAME::GameOver)
return;
Finally lets display a big game over message to the player. First off we setup a new image map using the leveleditor. We want the sprite to display in front of the game well/hud etc so we'll put it on layer 5. Drop it in the level editor, resize it and set the layer. Call it GameOverImage. Then uncheck visible. Then we need to add the following to gameScripts/game.cs
function displayGameOver()
{
GameOverImage.setVisible(true);
}
Congratulations! If you've followed and understood all the steps above you should now be the proud owner of a working albeit simple Tetris clone that matches the image back at the beginning of this tutorial. Here is the completed source: [[1]] Here is the complete source for my amended version of this great tutorial:
Tetris Source
|
|
[edit] Where to Next?So there you have it, a rough Tetris implementation. We have taken a number of short cuts that may not be realistic if you wanted for example smooth block movements or better rotation. However for the Tetris feel we were aiming for, those shortcuts have worked well. Despite being rough around the edges, you should hopefully have now have enough of an insight to go ahead and create a much more playable Tetris clone. So where can you go next? How can you improve it?
Last but by no means least, if you're still learning your way around TGB why not try coming up with a completely different method to implement tetris without the use of tilemaps. You could also find a method that allows your blocks to fall smoothly from the top of the screen downwards rather than jumping a line at a time. Then theres new varients with all sorts of neat effects for example rotate the play area as time goes by and use TGB's built in physics to cause the various blocks to tumble around. I hope you enjoyed this tutorial and took the time to fix any spelling mistake or errors that have crept in :) - Gary Preston
|
Categories: Needs Updating | T2D | TGB | GameExample | Tutorial | Getting Started












