Creating a 3D Tetris Game for Dummies Like Me — VIII
TL;DR: live demo or the advanced game + some shameless advertising wrapper.
Level: for beginners.
See detailed links and table of contents at the bottom of the article.
The last… well, almost… part! I still need to add some control to the game because not allowing the user to move the shape is a bit harsh. Nice but harsh.
The most simple things are move to the left and to the right. Pretty obvious, I just need to check if there is room to move in all the falling cells, and if so, then move.
// App.svelte
import {
removeFilledLines,
fallDown,
moveFigureLeft,
moveFigureRight,
} from "./game-utils";
...
function processKeysDown(ev) {
...
if (e === "ArrowLeft") {
moveLeft();
}
if (e === "ArrowRight") {
moveRight();
}
...
}
function moveLeft() {
if (paused || !started || gameOver) {
return;
}
moveFigureLeft(GameField);
tickManager.immediateRestart();
}
function moveRight() {
if (paused || !started || gameOver) {
return;
}
moveFigureRight(GameField);
tickManager.immediateRestart();
}
And implementation:
// game-utils.ts
function traverseBottomTopRight(field, callback: (row, col) => void) {
for (let row = 0; row < field.length; row++) {
for (let col = field[row].length - 1; col >= 0; col--) {
callback(row, col);
}
}
}
export function moveFigureLeft(GameField) {
let canMove = true;
traverseBottomTop(GameField, (row, col) => {
const block = GameField[row][col];
if (block !== 1) {
return;
}
if (col === 0 || GameField[row][col - 1] === 2) {
canMove = false;
}
});
if (!canMove) {
return;
}
traverseBottomTop(GameField, (row, col) => {
const block = GameField[row][col];
if (block !== 1) {
return;
}
GameField[row][col - 1] = 1;
GameField[row][col] = 0;
});
}
export function moveFigureRight(GameField) {
let canMove = true;
const width = GameField[0].length;
traverseBottomTop(GameField, (row, col) => {
const block = GameField[row][col];
if (block !== 1) {
return;
}
if (col === width - 1 || GameField[row][col + 1] === 2) {
canMove = false;
}
});
if (!canMove) {
return;
}
traverseBottomTopRight(GameField, (row, col) => {
const block = GameField[row][col];
if (block !== 1) {
return;
}
GameField[row][col + 1] = 1;
GameField[row][col] = 0;
});
}
Very, very straightforward code. Pity the rotation isn’t that simple.
First I’m adding the keyboard event processing:
// App.svelte
import {
removeFilledLines,
fallDown,
moveFigureLeft,
moveFigureRight,
rotateFigure,
} from "./game-utils";
...
function processKeysDown(ev) {
...
if (e === "ArrowUp") {
// rotate
rotate()
}
...
}
...
function rotate() {
if (paused || !started || gameOver) {
return;
}
if (rotateFigure(GameField)) {
tickManager.immediateRestart();
}
}
and implementation:
export function rotateFigure(field) {
// 1. scan for figure
let figure = [];
let minIdx = field[0].length;
let maxIdx = 0;
let firstRow = -1;
for (let row = field.length - 1; row >= 0; row--) {
let line = '';
for (let col = 0; col < field[row].length; col++) {
if (field[row][col] === 1) {
line += '#';
if (col < minIdx) {
minIdx = col;
}
if (col > maxIdx) {
maxIdx = col;
}
if (firstRow < row) {
firstRow = row;
}
} else {
line += ' ';
}
}
if (line.trim()) {
figure.push(line);
}
}
const rawWidth = maxIdx - minIdx + 1;
const rawHeight = figure.length;
if (rawWidth === rawHeight) {
// square, nothing to do
return false;
}
// 2. Transpond figure
figure = figure.map(line => line.substring(minIdx, minIdx + rawWidth));
if (firstRow > 22) {
return false;
}
const newFigure = [];
for (let i = 0; i < rawWidth; i++) {
newFigure[i] = new Array(rawHeight);
}
for (let row = 0; row < newFigure.length; row++) {
for (let col = 0; col < newFigure[row].length; col++) {
if (figure[col][row] !== ' ') {
newFigure[newFigure.length - row - 1][col] = '#';
}
}
}
// 3. Cleanup copy of field...
const tempField = copyField(field);
traverseBottomTop(tempField, (row, col) => {
if (tempField[row][col] === 1) {
tempField[row][col] = 0;
}
});
// 4. ...and put new figure there, if possible
let allow = true;
let nRow = 0;
for (let row = firstRow; row > firstRow - newFigure.length; row--) {
for (let col = minIdx; col < minIdx + rawHeight; col++) {
const nCol = col - minIdx;
if (nRow >= 0 && nRow < newFigure.length && nCol >= 0 && nCol < rawHeight) {
if (row < 0 || col < 0 || col >= tempField[0].length) {
allow = false;
}
if (newFigure[nRow][nCol]) {
if (tempField[row][col] === 2) {
allow = false;
}
if (allow) {
tempField[row][col] = 1;
}
}
}
}
nRow++;
}
// 5. If allowed, copy transponded figure to the real field
if (!allow) {
return false;
}
traverseBottomTop(field, (row, col) => {
if (tempField[row][col]) {
field[row][col] = tempField[row][col];
} else {
field[row][col] = 0;
}
});
return true;
}
export function createGameField() {
const field = new Array<Array10>(24) as TGameField;
for (let row = 0; row < field.length; row++) {
if (!field[row]) {
field[row] = new Array<number>(10) as Array10;
}
for (let col = 0; col < field[row].length; col++) {
field[row][col] = 0;
}
}
return field;
}
function copyField(GameField) {
const tempField = createGameField();
traverseBottomTop(GameField, (row, col) => {
if (GameField[row][col]) {
tempField[row][col] = GameField[row][col];
}
});
return tempField;
}
Uh oh.
Well, let’s explain.
First, I extract a piece from the playing field and build its string representation. To do this, I scan the field and place the corresponding falling blocks (1
) on lines and spaces if no block falls on that position. In other words,
Then trim extra spaces.
That’s because I don’t keep track of the figure: after being added to the playing field, it’s just a set of falling blocks.
Then I transpose the figure. I create new array with swapped measures (i.e., 4x1 instead of 1x4 for I
):
And the most dirty trick: I create a copy of the playing field, remove all falling blocks from it and try to put a new transposed figure in the original coordinates. If I can’t (too close to a wall or a solid block interferes), I reject the operation. If I can, I replace the original field with a copy that contains the transposed figure.
Yes, I also laughed for a long time when I came up with this method, but then I thought — what the hell, why not? Works with any configuration of falling blocks and does not depend on anything, only direct processing of the playing field, I don’t care about performance and optimisations… leave it as is.
The result of this article: all the game logic is done. Almost completed! Just need to add some sounds and play around with the fonts a bit…
Here is the GitHub repository with a snapshot of the stage.
To be continued … I promise, there is only one part left.
All the parts on Medium:
- Introduction and setting up the environment.
- My first 3D object.
- Next 3D figure: character generator and bitmap fonts.
- Keyboard Help 3D component: embossing SVG curves.
- Game field. Auto-adjusting Camera to the field.
- Life cycle and states. Banners. Time-based animations.
- Let it fall: almost live. Active game field, Two-phased turns.
- Gameplay as it is. Catch, move and rotate. ← you are here
- Sounds and Tuning it up.
Relevant parts on GitHub:
- https://github.com/lexey111/tetris3d-stage-0
- https://github.com/lexey111/tetris3d-stage-1
- https://github.com/lexey111/tetris3d-stage-2
- https://github.com/lexey111/tetris3d-stage-3
- https://github.com/lexey111/tetris3d-stage-4
- https://github.com/lexey111/tetris3d-stage-5
- https://github.com/lexey111/tetris3d-stage-6
- https://github.com/lexey111/tetris3d-stage-7 ← you are here
- https://github.com/lexey111/tetris3d-stage-8