- Introduction to lua
- Basic Setup
- Estrela for Luxinia
- Scenegraphs
- Scene Setup
- Models/Meshes
- Input handling
- Basic physics
- Physics surface properties
- Vehicle physics
- Using models
- Picking
- A Simple UDP Server
- GUI for the UDP Server
- Tetris attack clone prototype
- Stencil Shadows
- Sprite animation with matobject
- Project archives
- Estrela for Cg
- Specular mapping shader
- Material and Matobject
Writing a tetris prototype
Tetris Attack is a game created by Nintendo in 1995, for further information, please follow to the official website or its wikipedia entry
This tutorial shows, how to implement a Tetrix Attack clone prototype using lua. It shows how to
- Implement the gameplay
- Handling keyboardactions
- Creating the game field
- Create a menu
- Implement a scoring system
- Implement a gameover or penalty system
- Different playmodes or levels
Here's a video how the prototype will look like:
Gameplay
If you donnot know the gameplay, these are (in short) the rules:
- Blocks have different colors
- The player controls a cursor which always includes two blocks on a horizontal aligned row
- If the player presses an action key (spacebar), the two blocks will swap
- If a row of blocks, vertical or horizontal aligned has at least 3 blocks of the same color, it vanishs
- Blocks will drop if the block below vanishs, or if they are moved into the air
- The blocks move upward and new randomly colored blocks are generated at the bottom line.
- The game is lost, if the blocks reached the top row, however we won't implement this, instead we just stop the movement
- The scoring is based on (though this is also not implemented here)
- How many blocks are solved by a moved there's a maximum of 14 blocks that can vanish if two blocks are exchanged)
- Chain reactions will add up. Advanced players can often create chain reactions that include 4 solves one after another, or even up to 8 or 10 chain solves.
Step 1: Program structure and initialization
For this example, I chose to implement everything based on tables. It is not using an object oriented design, though it would be quite easy to implement multiple gamestates that run simultanly, which could be useful for multiplayer actions.The video shows, that we have a set of rows with blocks that have different colors. We have a cursor (that is moved using the arrow keys) and if we solve a set of colored blocks by moving them in a vertical or horizontal row of at least 3 same colored blocks.
We create a global table first called game, which holds our gamefield and various gameplay parameters that define the difficulty of our game:
view = UtilFunctions.simplerenderqueue () -- default start
view.rClear:colorvalue(0.0,0.0,0.0,0) -- black background
game = { -- our gameplay structure
field = { -- the field
w = 6, -- width of the field
h = 14 -- height of the field
},
colors = { -- our colors that the blocks may have
{1,0,0,1}, -- red
{0,1,0,1}, -- green
{0,0,1,1}, -- blue
{1,1,0,1}, -- yellow
{.75,0,1,1}, -- violet
{1,.5,0,1}, -- orange
--{0,1,.75,1}, -- ultra difficult mode :)
},
cursor = actornode.new("cursor"), -- our cursor
mincolors = 3, -- how many colors have to be in a row
cam = actornode.new("camera"), -- our camera actor
sun = actornode.new("sun"), -- our sun actor
blockspeed = 0.15, -- how fast the blocks will exchange
yspeed = 0.004, -- the vertical moving speed
keyrepeattime = 175 -- how long to pause after the
-- key was pressed before "pressing" it again
}
We have precreated here some actors, but most of it has to be initialized first before it can be used:
l3dcamera.default():linkinterface(game.cam) -- link the cam
l3dcamera.default():fov(-15) -- negative values for the fov
-- result in a orthogonal projection
game.cam:pos(-1,1,5) -- position the camera
game.cam:rotdeg(-100,-10,0) -- set the right angle
game.cursor.mdl = -- our cursor model and ...
model.load("selector.f3d",false,true,false,true)
-- ... don't let it use display lists, as we might
-- change the colors afterwards
game.cursor.l3d = l3dmodel.new("cursor",game.cursor.mdl)
-- create the model
game.cursor.l3d:linkinterface(game.cursor) -- link it
game.cursor.l3d:rfLitSun(true) -- let it be lit
game.cursor:pos(.5,0,0) -- set the initial position
game.sun.light = l3dlight.new("sun") -- our light source
game.sun.light:linkinterface(game.sun) -- link it
game.sun.light:makesun() -- make it our sun light
game.sun:pos(1000,1000,2000) -- position it somewhere
game.sun.light:ambient(.3,.3,.3,1) -- a bit of ambient
-- lightning, so not everything becomes pitch black
Our first initialisation will be the gamefield. We need now 6x14 blocks, which are created by using actors. The number of actors and l3dnodes will be constant as we switch the showing state. This way, we only need to create the scene once in the beginning.
We also will use a few models now, instead of primitive box geometry. The field will not be stored in a two dimensional array, though that would work, too, instead, we will use a simple field, where each index stands for a 2D position. We can calculate an index by using the x and y coordinate and multiply the width of the field with the y coordinate:
index = x + y * width
We can also calculate the position coordinates by some division and modulo operations on the index:
x = index % width y = math.floor(index / width)
As lua arrays start with index one and not with 0, we will need to add or subtract 1, depending on the wanted result.
Using this adressing system has advantages and disadvantages, but it is a commonly used way how to create two dimensional structs.
- It is often faster
- Adressing elements is a simple calculation
- Traversing the elements can be done in a simple loop
- However we need to do some calculations to retrieve the position or the index
local boxmdl = model.load ("box.f3d",false,true,false,true) -- the boxmodel that we want to use -- if the boxmodel is loaded as a display list, we cannot -- change the color of the block, so we need to prevent -- this -- iterating now over all elements (w*h) for i=1,game.field.w*game.field.h do local box = actornode.new("block") -- our box function box:randomcolor(encolor) -- a function that -- will set a new random color for this block box.colorid = math.random(#game.colors) -- a random color box.origcolorid = box.colorid -- a backup reference - -- we will modify the colorid variable later if encolor then -- if the argument was true box.l3d:color(unpack(game.colors[box.colorid])) -- set the colors of the block else box.l3d:color(0,0,0,1) -- otherwise, intialize it with -- a black color end box.l3d:rfNodraw(false) -- force it to be drawn --(important for later) end box.l3d = l3dmodel.new("block",boxmdl) -- our box model box.l3d:rfNovertexcolor(true) -- don't use the vertex -- colors of the model, instead use our own colors box.l3d:linkinterface(box) -- link it box.l3d:rfLitSun(true) -- let it be lit box:randomcolor(true) -- and colorize it now local x,y = -- calculate the coordinates now (i-1)%game.field.w-game.field.w/2, -- we center the field math.floor((i-1)/game.field.w)-game.field.h/2 -- around -- 0,0, that's why we shift it box:pos(x,y,0) -- setting the initial position now game.field[i] = box -- assign the box to our field, using -- the index end
Step 2: Thinking
We will define now the think method which will process our gamelogic. Our first step will be to adjust the fieldy variable. Since we want to stop the movement if a unsolved block reaches the top, we will check the topline and set a stop variable and won't move the field then:
local fieldy = 0 -- our vertical movement variable function game.think () -- our think method local dontmove = false -- we won't move if top is reached for x=1,game.field.w do -- let's see if this is the case local i = x + (game.field.h-1) * game.field.w -- ^^ top row index if game.field[i].colorid>0 then dontmove = true -- we set colorid to a negative value -- if the block is vanished, if this is not negative, -- we know that the top is reached by a block break -- it won't become more true than that end end -- if we are moving it up now, let's add the -- speed variable or don't, if we don't move fieldy = fieldy + (dontmove and 0 or game.yspeed)
Our next step will be to colorize the blocks that move in - we don't want them to pop in but to fade in from black to their appropriate color. As we move the field always by one unit up (a block is a cube of one unit size, which makes calculating everything very simple) and shift then the blocks into the next row, our fieldy variable will always have a value between 0 and 1 - just perfect for using it for modulating the bottom row:
-- We want the first row to fade in - fieldy is a -- value between 0 and 1 and if it is 1, the next -- row will start. So we just modulate the color with -- fieldy for x=1,game.field.w do local box = game.field[x] -- our box in first row local r,g,b,a = unpack(game.colors[box.colorid]) -- ^^ desired color of the block r,g,b = r*fieldy,g*fieldy,b*fieldy -- modulated (darker) box.l3d:color(r,g,b,a) -- set it end
Once we've done that, we will see if our field has a configuration where rows are solved and we will als want then to let the blocks drop if they float in the air. However, we will do this using helper functions and won't paste the code here. Both functions will be described in step 3:
game.field.solve() -- a function that seeks rows that
-- can vanish (solved)
game.field.gravity() -- let blocks drop down if they
-- float in the air
If our fieldy variable is larger than 1, we need to move the blocks up. This is where the single array adressing method pays off - in order to move the blocks to the bottom, we only need to remove the top row, which are the last 6 elements and insert them in first place:
-- insert top row in bottom row if fieldy >= 1 then fieldy = fieldy - 1 for i=1,game.field.w do local o = table.remove(game.field) table.insert(game.field,1,o) local px,py,pz = o:pos() o:randomcolor(false) -- set a new random color and -- make it black o:pos(-i+game.field.w/2,-game.field.h/2,0) -- set the new position o.vanishanim = nil -- our vanishanimation variable needs to be reseted end endWe will see later that we can use the index in the table to calculate the position of the block. This way, we can calculate the position where the block should be and correct its position, moving automaticly towards the correct position in our field - which makes changes in the field very simple.
For the next step, we want to set the cursor's position. As we don't want to make too many steps, we need a delay system, that allows us to wait a bit before the cursor is moved again, as long as the user holds down the key:
local t = system.time() -- current time in milliseconds local kd = Keyboard.isKeyDown -- short access to keypress local mx = -- our x movement for the cursor -((kd "left" and 1 or 0) + (kd "right" and -1 or 0)) local my = -- our y movement for the cursor ((kd "up" and 1 or 0) + (kd "down" and -1 or 0)) local cx,cy = mx,my -- we will correct the movement ... local lmxt,lmyt = t,t -- based on the last time we pressed -- the keys if game.cursor.lastmx == mx and -- same movement as before t-game.cursor.lastmxt<game.keyrepeattime then -- we have pressed the keys just a few millis ago cx = 0 -- set our x movemnt to 0 lmxt = game.cursor.lastmxt -- and don't change time -- when we made the press end if game.cursor.lastmy == my and -- same movement as before t-game.cursor.lastmyt<game.keyrepeattime then -- the same as for y cy = 0 lmyt = game.cursor.lastmyt end -- remember now what the user pressed in this frame game.cursor.lastmx, game.cursor.lastmy = mx,my game.cursor.lastmxt,game.cursor.lastmyt = lmxt,lmyt
As you see, this is quite complex and maybe it should be replaced by a generalized function, as it is quite the same code for each key that we check. As long as it doesn't get more than three keys, it is acceptable.
We will check now if the user pressed space and swap then the blocks:
if kd "SPACE" and (not game.cursor.lastswitch or t-game.cursor.lastswitch>game.keyrepeattime) then -- after checking the time again ... local x,y = game.cursor:pos() -- current position x,y = -- we need a x/y coordinate that is integer x + game.field.w/2+.5, math.ceil(y + game.field.h/2 - fieldy -.5) local i = x + y*game.field.w -- lets calculate our -- index in the field local a,b = i,i+1 -- we swap a and b now game.field[a],game.field[b] = -- swapping in lua is game.field[b],game.field[a] -- really easy :D game.cursor.lastswitch = t -- remember not -- to swap again for some time end -- a small helper function to let the value be between -- a given minimum and maximum value local function mima (min,max,val) return math.min(max,math.max(min,val)) end local x,y = game.cursor:pos() local x,y = -- limit the position of the cursor horizontal mima(-game.field.w/2+.5,game.field.w/2-1.5,x+cx), mima(-game.field.h/2+fieldy,game.field.h/2-1 + fieldy,y+cy+game.yspeed) --and vertical -- set the cursor position and make sure y to be inline game.cursor:pos(x,math.ceil(y-.5-fieldy)+fieldy,0)
As we see above: handling the keyboard input right is more complex than the gameloging for handling the reaction in our field.
Our think function is now nearly finished. We need now to make our blocks move around
-- for each block ... for i,box in ipairs(game.field) do -- calculate the (desired) position, based on the index local x,y = (i-1)%game.field.w-game.field.w/2, math.floor((i-1)/game.field.w)-game.field.h/2 + fieldy local px,py,pz = box:pos() -- current position local dx,dy = x-px,y-py -- difference to the desired pos -- if our block is near its desired location, it is -- inposition game.field[i].inposition = dx*dx+dy*dy<.01 -- move the block, but only with constant velocity local mx,my = mima(-game.blockspeed,game.blockspeed,dx), mima(-game.blockspeed,game.blockspeed,dy) dz = dx -- if the block moves to the right, make it -- move to the front, if it moves to the left, it -- moves behind - this simulates the "rotation" if box.vanishanim then -- if the box is vanishing if box.vanishanim>1 then -- we don't need to draw it box.l3d:rfNodraw(true) -- if it is finished else -- otherwise, simulate the movement to the back -- and modulate the color to black box.vanishanim = box.vanishanim +.02 -- step forward -- in animation box:pos(px,py,-box.vanishanim*2) -- move it behind local r,g,b,a = unpack(game.colors[box.origcolorid]) local sub = box.vanishanim -- modulate colors r,g,b = math.max(0,r-sub),math.max(0,g-sub), math.max(0,b-sub) box.l3d:color(r,g,b,a) -- and set the color end else -- otherwise, we just move towards our game.field[i]:pos(px+mx, -- desired location py+(math.abs(dx)<.1 and my or 0),dz) end end end
This is the think method and we only need to register it now:
Timer.set("Gametimer",game.think,20)
What is missing now is the solving code and the gravitation effect.
Step 3: Solving and gravitation
The gravity code is fairly simple: We just check every row and move up and look, if the element we are looking at is free, and if it is, we just let the element above drop down:
-- the gravity handling function game.field.gravity () for x=1,game.field.w do -- iterate ove each ROW ... for y=1,game.field.h-1 do -- ... upwards local i = x+(y-1)*game.field.w local i2 = x+y*game.field.w -- i: index of element -- i: index of element above if game.field[i].colorid<0 then -- block it is 'empty' game.field[i2].onground = false -- above cannot be -- on its ground game.field[i],game.field[i2] = -- swap above game.field[i2],game.field[i] -- and below -- (this is causing a constant swapping of -- free elements, swapping is just as expensive -- as an additional check if swapping is required -- so this is kinda senseless to check elseif game.field[i].onground or y==1 -- our element then -- is on ground, or its the bottom line if game.field[i2].inposition then -- if element -- above is in position ... game.field[i2].onground = true -- it is also on the -- ground end end end end end
So this leaves only the solving function open - this is a bit more complex, since we have to check in each direction if a number of same colored blocks are in a row, solve them then, check in the other direction and set them then as solved.
We keep a record of the current color, how many elements we've collected so far and keep them in a list. We use four helper functions, to manage the lists:
-- solving blocks function game.field.solve () -- a few local functions, which keep our scanned results local colcount,colid = 0,nil -- colorcound and colorid -- colorid: current color in the row local remlist,rowlist = {},{} -- removelist and rowlist local invalidatelist = {} -- list of elments that --need to be invalidated -- the rowlist is the list of scanned elements that -- are of the same color local function startrow() -- starting a new row will need us to reset some vars colcount,colid = 0,nil -- no colors counted so far remlist,rowlist = {},{} --empty remove and rowlist end -- once we have finished a scanline or another color -- starts, we check if we can make the blocks vanish local function finishappend() if rowlist and colcount >= game.mincolors then -- we have a rowlist and there are more then 3 -- in a row for i,v in ipairs(rowlist) do -- insert the elements table.insert(remlist,v)-- of the row in the removelist end end end -- test if we can append a block to our row -- which can be done if has the same color local function appendtest (x,y) local i = x+(y-1)*game.field.w -- calculate the index local color = game.field[i].colorid -- this is the color if rowlist and colid==color and color>0 and -- we have game.field[i].inposition and -- a row and color, it is game.field[i].onground -- inposition and on the ground then colcount = colcount + 1 -- append it to the row table.insert(rowlist,i) else -- otherwise, we need to check if a row has been finishappend() -- finished if game.field[i].onground and game.field[i].inposition then -- it is a valid block to start a new list rowlist = {i} else rowlist = nil -- or it is not end colid = color -- anyway, set the color id colcount = 1 -- and set the elementcount to 1 end end -- check now the elements to be removed local function check() for i,v in ipairs(remlist) do game.field[v].vanishanim = 0 -- init vanishanim -- (which starts the pushback/fadeout animation) table.insert(invalidatelist,game.field[v]) -- we cannot change the colorid now, we need to -- wait till the end end end
We need now to call these helpers in two directions: vertical and horizontal and for each column and each row. This can be done using two nearly identical for loops, but each one has a swapped order of x/y:
-- up to down check for x=1,game.field.w do startrow() -- start a new row for y=2,game.field.h do appendtest(x,y) -- append if possible end finishappend()-- finish the row check() -- and check out the remlist list end -- left to right check for y=2,game.field.h do startrow() -- similiar to the loop before for x=1,game.field.w do appendtest(x,y) end finishappend() check() end
Finally, we invalidate our blocks that have been removed so that they are no longer used for building rows:
for i,v in ipairs(invalidatelist) do v.colorid = -1 -- invalidate all removed blocks end end
This function would needed to be extended if a scoring system would be added.
