Creating a 3D Tetris Game for Dummies Like Me — V
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.
At the moment I have the Next Figure and Keyboard Help 3D components. I decided to show everything else (banners, score, level) as a simple HTML div
, so I seem to have everything to get started with gameplay?
No. I need a Scene first. Remember the very first component I created? Only with a cube? Yes, that one. Now I need to make this component able to display the playfield.
All game logic will be in App.svelte
, but the visual representation is the task of the Scene
component.
This part will be especially long; don’t worry, it’s simple. Just long.
Think a little about the playing field itself. This is a 20 row, 10 column field… or Array
for Javascript. Also I would add an extra 4 lines for the next shape (starting position).
Nice. How I will encode cell state? As simple as–
0
— empty cell,1
— cell of falling figure (dynamic cell),2
— a solid cell (static),3
— a solid cell to be deleted. In other words, a filled line. Why I need to have separate status for it? Because I want to display the removal process and also make the gameplay code a bit more sophisticated.
What should I do first… no, not creating a component. Not even creating a playfield array. First I need to provide a valid container and layout.
Well, let’s return to App.svelte
Here is a layout I want to have:
Corresponding App.svelte
:
<script lang="ts">
import Keys from "./components/Keys.svelte";
import Next from "./components/Next.svelte";
import Scene from "./components/Scene.svelte";
const figures = "SZLJITO".split("");
let figure = "";
setInterval(() => {
figure = figures[Math.floor(Math.random() * figures.length)];
}, 500);
</script>
<main>
<div id="main-container">
<div id="scene-container"><Scene/></div>
<div id="info-container">
<Next {figure} />
<div id="score-container">
<h1>100</h1>
<h2>level 2</h2>
</div>
</div>
</div>
<div id="help-container">
<Keys />
</div>
</main>
<style>
#main-container {
display: flex;
flex-flow: row nowrap;
align-items: center;
align-content: center;
justify-content: center;
position: absolute;
top: 0;
left: 0;
width: 100%;
bottom: 100px;
background-color: rebeccapurple;
}
#scene-container {
background-color: bisque;
width: auto;
aspect-ratio: 10 / 24;
height: 100%;
align-self: center;
position: relative;
margin-left: 60px;
}
#info-container {
height: 100%;
width: 120px;
text-align: center;
background-color: cadetblue;
}
#info-container h1,
#info-container h2 {
font-weight: normal;
margin: 0;
padding: 0;
}
#help-container {
display: flex;
justify-content: center;
position: absolute;
left: 0;
bottom: 0;
width: 100%;
background-color: aquamarine;
}
</style>
And browser shows:
Pretty straightforward one. The only interesting thing here — calculation of scene dimensions:
#scene-container {
width: auto;
aspect-ratio: 10 / 24; /* let's CSS do all the work for us! */
height: 100%;
align-self: center;
position: relative;
margin-left: 60px; /* move a bit to right, to be more centered */
}
Quick check on Can I Use…
Nice, 91.67%. I don’t even want to worry about these 8% of marginals, they can always buy a more modern browser. Anyway, any game that respects itself has decent system requirements, and let they be thankful that I don’t demand a GeForce Ti 5090.
Time to change the Scene.svelte
? Not yet. First, here, in App.svelte
I have to create a game field.
But even before first I need to declare some types:
// components/types.ts
type GrowToSize<T, N extends number, A extends T[]> = A['length'] extends N ? A : GrowToSize<T, N, [...A, T]>;
type FixedArray<T, N extends number> = GrowToSize<T, N, []>;
type CellType = 0 | 1 | 2 | 3;
type Tuple<
T,
N extends number,
R extends T[] = [],
> = R['length'] extends N ? R : Tuple<T, N, [T, ...R]>;
export type Array10 = Tuple<CellType, 10>;
export type TGameField = FixedArray<Array10, 24>;
// App.svelte
import type { Array10, TGameField } from "./components/types";
...
// create an empty game field
const GameField:TGameField = new Array<Array10>(24) as TGameField;
// and fill it with random values (num === -1)
fillField(-1);
printGame(GameField);
function fillField(num) {
for (let row = 0; row < GameField.length; row++) {
if (!GameField[row]) {
GameField[row] = new Array<number>(10) as Array10;
}
for (let col = 0; col < GameField[row].length; col++) {
// num can be a static value, or if it is negative, random value 0..3
GameField[row][col] =
num >= 0
? num
: Math.random() > 0.7
? Math.floor(Math.random() * 4)
: 0;
}
}
}
function printGame(GameField:TGameField) {
for (let i = GameField.length - 1; i >= 0; i--) {
let s = i.toString().padStart(2, " ") + " ";
for (let j = 0; j < GameField[i].length; j++) {
if (GameField[i][j] === 0) {
s += ".";
}
if (GameField[i][j] === 1) {
s += "F";
}
if (GameField[i][j] === 2) {
s += "S";
}
if (GameField[i][j] === 3) {
s += "X";
}
}
console.log(s);
}
}
...
<div id="scene-container">
<Scene {GameField}/>
</div>
Here you can see all the most useful techniques for game development: global variables, function with side effects... all for the sake of performance, of course!
In console I see correct output. So, the best moment to start render the data, yeah.
I removed all the garbage, and this is “empty” Scene.svelte
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import * as THREE from "three";
import type { TGameField, TThreeFrame } from "./types";
export let GameField: TGameField;
let Frame: TThreeFrame;
let canvas;
onMount(() => {
initScene();
});
onDestroy(() => {
if (!Frame) {
return;
}
Frame.renderer.dispose();
Frame.renderer.forceContextLoss();
Frame.renderer.domElement = null;
Frame.renderer = null;
});
function initScene() {
Frame = {
scene: new THREE.Scene(),
renderer: new THREE.WebGLRenderer({ alpha: false, antialias: true }),
camera: new THREE.PerspectiveCamera(
75,
canvas.offsetWidth / canvas.offsetHeight,
0.1,
500
),
};
Frame.camera.zoom = 5;
Frame.camera.position.set(0, 0, 10);
Frame.camera.updateProjectionMatrix();
const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(0, 14, 30);
light.castShadow = true;
const pointLight1 = new THREE.PointLight(0x888888ff, 0.8, 10);
pointLight1.position.set(-4, -4, -4);
pointLight1.castShadow = true;
const pointLight2 = new THREE.PointLight(0xff0088, 0.8, 20);
pointLight2.position.set(4, -3, -6);
pointLight2.castShadow = true;
Frame.scene.add(light);
Frame.scene.add(pointLight1);
Frame.scene.add(pointLight2);
Frame.renderer.shadowMap.enabled = true;
Frame.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
Frame.renderer.setSize(canvas.offsetWidth, canvas.offsetHeight);
canvas.appendChild(Frame.renderer.domElement);
Frame.renderer.render(Frame.scene, Frame.camera);
}
</script>
<div id="scene" bind:this={canvas} />
<style>
#scene {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
</style>
Just a black rectangle. I want to have this one:
Well, there are walls and points. Let’s start with them.
This is special file dedicated to create scene objects:
// components/scene-objects.ts
import * as THREE from "three";
const wallGeometry = new THREE.BoxGeometry(0.9, 0.9, 1.5);
const wallCubeMaterial = new THREE.MeshStandardMaterial({color: 0x0095DD});
const wallCube = new THREE.Mesh(wallGeometry, wallCubeMaterial);
function createWallBrick(x, y, z) {
const cube = wallCube.clone();
cube.position.set(x, y, z);
cube.castShadow = true;
cube.receiveShadow = true;
return cube;
}
export function createWalls() {
const wallGroup = new THREE.Group();
// left
const verticalWall = new THREE.Group();
for (let i = 0; i < 20; i++) {
verticalWall.add(createWallBrick(0, i, 0));
}
// right
const verticalWall2 = verticalWall.clone();
verticalWall.position.setX(-5 - .5);
verticalWall.position.setY(-9 - .5);
verticalWall2.position.setX(5 + .5);
verticalWall2.position.setY(-9 - .5);
wallGroup.add(verticalWall);
wallGroup.add(verticalWall2);
// bottom
const bottomWall = new THREE.Group();
for (let i = 0; i < 12; i++) {
bottomWall.add(createWallBrick(i, 0, 0));
}
bottomWall.position.setX(-6 + .5);
bottomWall.position.setY(-10 - .5);
wallGroup.add(bottomWall);
return wallGroup;
}
const spaceGeometry = new THREE.SphereGeometry(0.03, 4, 4);
const spaceMaterial = new THREE.MeshStandardMaterial({color: 0x9999FF});
export function createSpaceItems() {
const spaceGroup = new THREE.Group();
for (let i = 0; i <= 9; i++) {
for (let j = 0; j <= 19; j++) {
const item = new THREE.Mesh(spaceGeometry, spaceMaterial);
item.position.set(i, j, -1);
spaceGroup.add(item);
}
}
spaceGroup.position.setX(-5 + .5);
spaceGroup.position.setY(-10 + .5);
return spaceGroup;
}
There are two exported functions here, createWalls()
and createSpaceItems()
. I need just add their calls to initScene()
of Scene.svelte
:
function initScene() {
Frame = {
scene: new THREE.Scene(),
renderer: new THREE.WebGLRenderer({ alpha: false, antialias: true }),
camera: new THREE.PerspectiveCamera(
75,
canvas.offsetWidth / canvas.offsetHeight,
0.1,
500
),
};
Frame.camera.zoom = 1;
const light = new THREE.DirectionalLight(0xffffff, 2);
light.position.set(3, 40, 2);
light.castShadow = true;
light.rotateX(5);
Frame.scene.add(light);
const plight = new THREE.PointLight(0xddffff, 0.7, 50);
plight.position.set(4.5, 0, 4);
plight.castShadow = true; // default false
Frame.scene.add(plight);
const semiLight = new THREE.HemisphereLight(0x8080a0, 0x222222, 0.64);
Frame.scene.add(semiLight);
const ambientLight = new THREE.AmbientLight(0x505050);
Frame.scene.add(ambientLight);
Frame.renderer.shadowMap.enabled = true;
Frame.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
Frame.renderer.setSize(canvas.offsetWidth, canvas.offsetHeight);
canvas.appendChild(Frame.renderer.domElement);
Frame.scene.add(createWalls()); // <-- add walls
Frame.scene.add(createSpaceItems()); // <-- add space dots
adjustPerspectiveCamera(Frame.camera, 0.8); // <-- but what is it???
Frame.renderer.render(Frame.scene, Frame.camera);
}
Setting lights, adding walls, adding dots… pretty clear, but what is that adjustPerspectiveCamera()
? Uh, it is very useful function, I’ve got the idea here:
function adjustPerspectiveCamera(camera, offset) {
// https://wejn.org/2020/12/cracking-the-threejs-object-fitting-nut/
offset = offset || 1.5;
const boundingBox = new THREE.Box3(
new THREE.Vector3(-6, -12, -6),
new THREE.Vector3(6, 12, 6)
);
const size = new THREE.Vector3();
boundingBox.getSize(size);
const center = new THREE.Vector3();
boundingBox.getCenter(center);
camera.position.set(0, 0, 100);
const fov = camera.fov * (Math.PI / 180);
const fovh = 2 * Math.atan(Math.tan(fov / 2) * camera.aspect);
let dx = size.z / 2 + Math.abs(size.x / 2 / Math.tan(fovh / 2));
let dy = size.z / 2 + Math.abs(size.y / 2 / Math.tan(fov / 2));
let cameraZ = Math.max(dx, dy);
if (offset !== undefined && offset !== 0) cameraZ *= offset;
camera.position.set(0, 0, cameraZ);
const minZ = boundingBox.min.z;
const cameraToFarEdge = minZ < 0 ? -minZ + cameraZ : cameraZ - minZ;
camera.far = cameraToFarEdge * 3;
camera.lookAt(center);
camera.updateProjectionMatrix();
}
This allows you to automatically adjust, you’ll be surprised, the perspective camera to display the right area for guaranteed visibility. A very, very useful feature, especially if the viewport may be different: if I decide to make an adaptive design, then this is a must have.
I’d add the call of this function in resize callback, like
// on resize callback/event
...
// actual width, height calculation, like
// const width = canvaw.offsetWidth;
...
Frame.renderer.setSize(width, height);
(Frame.camera as THREE.PerspectiveCamera).aspect = width / height;
adjustPerspectiveCamera(Frame.camera as THREE.PerspectiveCamera, 0.8);
Frame.renderer.render(Frame.scene, Frame.camera);
Huh. The time to render, in the end!
Let’s add the render function:
// components/Scene.svelte
// at top
export let GameField: TGameField;
let renderField; // <-- 3D representation of GameField: group of cubes
...
onMount(() => {
initScene();
drawField(); // <-- add rendering the field
Frame.renderer.render(Frame.scene, Frame.camera);
});
...
const fallingMaterial = new THREE.MeshStandardMaterial({
color: 0xffa600,
// transparent: true,
// opacity: 0.5,
// side: THREE.DoubleSide
});
const deletingMaterial = new THREE.MeshStandardMaterial({
color: 0xff0000,
transparent: true,
opacity: 0.8,
side: THREE.DoubleSide,
});
function drawField() {
if (!Frame) {
return;
}
if (renderField) {
// I group everything related to game field in renderField group,
// and re-create its content and the group itself every time.
const objectsToRemove = [];
renderField.traverse((node) => {
if (node instanceof THREE.Mesh) {
objectsToRemove.push(node);
}
});
objectsToRemove.forEach((node) => {
node.parent.remove(node);
});
Frame.scene.remove(renderField);
renderField = null;
}
renderField = new THREE.Group();
for (let row = 0; row < 24; row++) {
// vertical
for (let col = 0; col < GameField[row].length; col++) {
// horizontal
if (GameField[row][col]) { // if not 0...
let cube;
if (GameField[row][col] === 3) {
cube = createCube(deletingMaterial);
}
if (GameField[row][col] === 1) {
if (!cube) {
cube = createCube(fallingMaterial);
}
}
if (!cube) {
cube = createCube(); // 2, solid
}
cube.position.x = col;
cube.position.y = row;
renderField.add(cube);
}
}
}
// shift all the scene to match coordinates...
renderField.position.x = -5 + 0.5;
renderField.position.y = -10 + 0.5;
renderField.position.z = 0;
Frame.scene.add(renderField);
}
…and it works:
The code is simple, like any other code here, but correctly displays a random playing field with all three types of dice: falling, solid, going-to-be-deleted.
So, are you all set? Not yet. Let’s check how it will be updated (spoiler: no way, yet).
I added super-simple dynamic code to App.svelte
which generates a random playing field every single second:
// App.svelte
setInterval(() => {
fillField(-1);
printGame(GameField);
}, 1000);
And I can see in the console that the field is changing… but not being redrawn.
Ah, for sure I need to add reactivity to Scene.svelte
! Ok:
// components/Scene.svelte
...
let Frame: TThreeFrame;
let canvas;
$: if (GameField && Frame) {
drawField();
Frame.renderer.render(Frame.scene, Frame.camera);
}
Just redraw the current field on every update. Works like clockwork! Almost as Apple Watch Ultra but better!
I removed backgrounds from styles and replaced
renderer: new THREE.WebGLRenderer({ alpha: false, antialias: true })
with
renderer: new THREE.WebGLRenderer({ alpha: true, antialias: true }) // alpha
everywhere. Also, I see inconsistencies and mismatches between the various parts, but let’s skip that for now.
So, the result of the fifth article: 3D scene component which is able to display the game field state and its changes. I’m getting closer and closer to the gameplay…
Here is the GitHub repository with a snapshot of the stage.
To be continued…
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. ← you are here
- 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.
- 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 ← you are here
- https://github.com/lexey111/tetris3d-stage-5
- https://github.com/lexey111/tetris3d-stage-6
- https://github.com/lexey111/tetris3d-stage-7
- https://github.com/lexey111/tetris3d-stage-8