Block Puzzle
Again, some time went by without another blog post. So I won’t put you off with something small. Instead this is going to be some bigger tutorial. We are going to (re)build a casual game with Svelte. How’s that?
Casual Games»
Recently, I was busy trying out some game ideas / concepts I had in my mind. But those are not ready to show off, yet.
At the moment it’s fashionable to make clones of Wordle or tutorials on how to write your own Wordle clone, surely there’s more then one done with Svelte, too. On a side note, I like the clones that recombine the Mastermind concept behind wordle with things other than words, e.g. like Mathler does with algebraic calculations.
But in this tutorial I’ll show you how easy it is to build this well known block puzzle game using Svelte. It’s already two years I encoutered Svelte and back then I did this Copycat when I went my first steps with Svelte.
Tl;dr – Let’s play»
Too Long, Didn’t Read.
Please, let me just play the game, now!
Oh ok, here you go! As we are going to build this game, you should know, how it feels.
So start off and drag the three parts below the playing field and place them inside the field. Try to fill lines, either in horizontal or vertical directions and see full lines disappear.
How long can you fit in more tiles?
Puzzle tiles»
So the game is obviously a puzzle game. You have to find a free spot for each of the three random puzzle tiles available for drag and drop. If you can place all three, you’ll get another three to repeat. If a horizontal or vertical line is completely filled with squares, these lines disapear. You get points for alle squares placed as well as all squares that disappear. If you can’t place any tile left, the game is over and you habe to start again.
Tiles»
There are 19 different tiles. So I first thought how to define these tiles? And for that I decided to use an array of strings, each string being a multi-line text tile definition.
You can take a look at the source code of
tiles.js
const tiles = [
`
###
###
###`,
`
##
##`,
`
#
#
###`,
`
#
#
###`,
`
###
#
# `,
`
###
#
#`,
`
#
##`,
`
##
#`,
`
#
##`,
`
##
# `,
`
#`,
`
#
#`,
`
##`,
`
#
#
#`,
`
###`,
`
#
#
#
#`,
`
####`,
`
#
#
#
#
#`,
`
#####`
].map(tile => tile.replace(/^\n/, ""));
export const getTile = (i) => tiles[i<tiles.length?i:0];
export const getTiles = () => tiles;
export const getRandomTile = () =>
tiles[Math.floor(Math.random() * tiles.length)];
getRandomTile()
to get one of those nineteen tiles. Each tile exists infinitely often and appears with approximately the same probability. This is perfectly adequate for the game, I think. Data structure»
While multiline text was handy for tile creation, another format makes more sense for later requirements of the game. A two-dimensional array might be the obvious choice, but I found a single or flat array along with some meta information to be more appropriate. And once decided, I followed that path until the end. The width would be completely sufficient as meta information, if you look at the code you will see that the height is never needed as it is implicit.
Conversion»
Here you can use the slider to view all 19 parts and their representation as the desired structure supplemented by the variant as a 2D-Array and the rendered result. We will talk about rendering a bit later. At the moment we need to convert text into data.
const tile = `
###
#
#`;
const field = {
"size": {
"width": 3,
"height": 3
},
"values": [1,1,1,0,0,1,0,0,1]
};
const field2d = [
[1,0,0],
[1,0,0],
[1,1,1]
];
For the conversion, it’s best to have a look at the convertTextToField(text)
method from the
comversion.js
import { nums } from "$lib/utils/math-utils";
import { objectFlip } from "$lib/utils/object-utils";
const LINEBREAK = "\n";
const EMPTY = " ";
const SQUARE = "#";
const valueCharMapping = {
0: EMPTY,
1: SQUARE
};
const convertValueToChar = (val) => valueCharMapping[val];
const convertCharToValue = (text) => parseInt(objectFlip(valueCharMapping)[text]);
const addLinebreakAtWidth = (position, width) => position % width == 0 ? LINEBREAK : "";
const returnMaxLength = (maxLength, line) => maxLength > line.length ? maxLength : line.length;
const fillUpWithEmptyTillLength = (line, length) => line + getEmpties(length - line.length);
const getEmpties = num => nums(num).fill(EMPTY).join('');
export const convertFieldToText = (field) =>
field.values.map((item, index) =>
convertValueToChar(item)
+ addLinebreakAtWidth(index+1, field.size.width)
).join("");
export const convertTextToField = (text) => {
const lines = text.split(LINEBREAK);
const width = lines.reduce(returnMaxLength, 0);
const height = lines.length;
text = lines.map(line => fillUpWithEmptyTillLength(line, width)).join('');
return {
size: { width, height },
values: text.split('').map(convertCharToValue)
};
};
So up until now we had tiles, the tile definition and random selection, and now conversion, the transformation of a multiline string tile into our data structure. I would call it a tile field. Everything from now on is fields.
Game logic»
Before we think about how to render tile fields with Svelte, we can have a look at
field.js
import { and, notZero, or } from "$lib/utils/bit-utils";
import { nums } from "$lib/utils/math-utils";
import { plus } from "$lib/utils/path-utils";
export const createField = (columns, rows, value) => ({
size: {
width: columns,
height: rows
},
values: nums(columns*rows).fill(value)
});
export const createSquareField = (size, value) => createField(size, size, value);
const coordsAndSizeToIndex = (coords, size) => coords.y * size.width + coords.x;
const indexAndSizeToCoords = (index, size) => ({
x: index % size.width,
y: index / size.width >>> 0
});
export const addTileToField = (tile, field, offset) => {
tile.values.forEach( (value, index) => {
if (value !== 0) {
const coords = plus(indexAndSizeToCoords(index, tile.size), offset);
field.values[coordsAndSizeToIndex(coords, field.size)] = value;
}
});
return field;
};
export const removeTileFromField = (tile, field, offset) => {
tile.values.forEach( (value, index) => {
if (value !== 0) {
const coords = plus(indexAndSizeToCoords(index, tile.size), offset);
field.values[coordsAndSizeToIndex(coords, field.size)] = 0;
}
});
return field;
};
export const tileFitsIntoFieldWithOffset = (tile, field, offset) => {
let doesFit = true;
if (offset.x + tile.size.width > field.size.width || offset.y + tile.size.height > field.size.height) {
doesFit = false;
} else {
tile.values.forEach( (value, index) => {
if (value !== 0) {
const coords = plus(indexAndSizeToCoords(index, tile.size), offset);
if (field.values[coordsAndSizeToIndex(coords, field.size)] !== 0) {
doesFit = false;
}
}
});
}
return doesFit;
};
export const tileFitsIntoField = (tile, field) => {
return nums(field.size.width * field.size.height)
.map(index => tileFitsIntoFieldWithOffset(tile, field, indexAndSizeToCoords(index, field.size)))
.reduce(or, 0);
};
const getColumn = (field, column) => {
return {
size: {
width: 1,
height: field.size.height
},
values: field.values.filter((item, index) => index % field.size.width === column)
};
};
const getRow = (field, row) => {
const start = row * field.size.width;
const end = start + field.size.width;
return {
size: {
width: field.size.width,
height: 1
},
values: field.values.slice(start, end)
};
};
export const getFullLines = (field) => {
let linesField = createField(field.size.width, field.size.height, 0);
nums(field.size.height).forEach(r => {
const row = getRow(field, r);
if (row.values.reduce(and, 1)===1) {
addTileToField(row, linesField, {x:0, y:r});
}
});
nums(field.size.width).forEach(c => {
const column = getColumn(field, c);
if (column.values.reduce(and, 1)===1) {
addTileToField(column, linesField, {x:c, y:0});
}
});
return linesField;
};
export const numSquares = (field) => field.values.filter(notZero).length;
export const convertFieldTo2dArray = function (field) {
let arr = [];
field.values.forEach((value, index) => {
let coords = indexAndSizeToCoords(index, field.size);
if (!Array.isArray(arr[coords.x])) {
arr[coords.x] = [];
};
arr[coords.x][coords.y] = value;
});
return arr;
};
export const fieldForView = (field) => field.values.map((value, index) => ({
index: index,
value: value,
coords: indexAndSizeToCoords(index, field.size)
}));
Create fields»
This starts quite simple, e.g. with the methods to create a rectangular or in particular a square field. We can recognize the data structure within the method again. The value can be 1 for set or 0 for and empty squares.
export const createField = (columns, rows, value) =>
({
size: {
width: columns,
height: rows
},
values: nums(columns*rows).fill(value)
});
export const createSquareField = (size, value) =>
createField(size, size, value);
Coordinates»
Of course, each value in our one-dimensional array has an index and thus represents a coordinate within the field. So we have little helpers to convert coordinates into an index or vice versa an index into coordinates. As already mentioned, we need the width meta information for this, but not the height.
const coordsAndSizeToIndex =
(coords, size) => coords.y * size.width + coords.x;
const indexAndSizeToCoords = (index, size) => ({
x: index % size.width,
y: index / size.width >>> 0
});
Manipulate fields»
When a user places a tile field on the playing field we require the addTileToField(tile, field, offset)
.
There we go through all the elements of our flat array data structure of the tile field. For all values not equal to zero we determine the coordinates, add the offset to position the tile in the field and finally set the value in the field at the calculated index.
export const addTileToField = (tile, field, offset) => {
tile.values.forEach( (value, index) => {
if (value !== 0) {
const coords = plus(indexAndSizeToCoords(index, tile.size), offset);
field.values[coordsAndSizeToIndex(coords, field.size)] = value;
}
});
return field;
};
And when a line of squares is all set disappears we need the counterpart method removeTileFromField(tile, field, offset)
. You can see them in the source code linked above.
I tried to write all methods in the spirit of functional programming. By that I mean, they are pure function without side effects, which always return the same result for the same parameters. In this case each time a new field data structure is returned.
Pre-check»
However, the previous mention methods addTileToField(tile, field, offset)
does not check whether the tile field still fits into the playing field at the given point. For this we have tileFitsIntoFieldWithOffset(tile, field, offset)
. Don’t worry, I won’t explain each method line by line.
And then there’s also more general method tileFitsIntoField(tile, field)
which checks whether the tile can be placed at any coordinate or index in the field. So we can detect, if a tile fits into the empty spaces of the playing field at all or if the game is already over.
More calculations»
Then again we have helpers to cut lines (rows or columns) from a field with getColumn(field, column)
or getRow(field, row)
. The methods also return a field.
And these methods are used by getFullLines(field)
which itself returns a new field. It first goes through the passed field row by row and in the second step column by column. When a line is fully set, it is added to the result field. It detects all completely filled rows or columns and combines them into a new field data structure. This allows us to remove them later all together, e.g. with the help of the remoteTileFromField
that we already discussed.
export const getFullLines = (field) => {
let linesField = createField(field.size.width, field.size.height, 0);
nums(field.size.height).forEach(r => {
const row = getRow(field, r);
if (row.values.reduce(and, 1)===1) {
addTileToField(row, linesField, {x:0, y:r});
}
});
nums(field.size.width).forEach(c => {
const column = getColumn(field, c);
if (column.values.reduce(and, 1)===1) {
addTileToField(column, linesField, {x:c, y:0});
}
});
return linesField;
};
The method numSquares(field)
calculates the number of all set squares or positions of a field. With this we can determine the points that a field is worth when adding or removing.
As a bonus convertFieldTo2dArray(field)
is the method to convert our data model, the flat array, into a 2D-Array as shown above, which I initially decided against.
Preparation»
And then we have the transformation of our data model into a form that makes rendering easier, a perfect transition to the next section about rendering. The fieldForView(field)
method enriches the values with an index and the 2D coordinates.
export const fieldForView = field => field.values.map((value, index) => ({
index: index,
value: value,
coords: indexAndSizeToCoords(index, field.size)
}));
Rendering»
Now we come to the wonderful world of Svelte Components. Svelte mainly takes care of two things for us, the two-way data binding and the reactivity. So a component changes according to their properties and it can also do some re-calculations without boilerplate code.
Squares»
The smallest pieces that tile fields are build upon are squares. A single
square.svelte
<script>
export let x = 0;
export let y = 0;
export let bg = false;
</script>
<div class={bg?"bg":""} style="left: {4+x*34}px; top: {4+y*34}px"></div>
<style>
div {
position: absolute;
width: 30px;
height: 30px;
background: #eecc88;
border-radius: 3px;
box-shadow: inset 1px 1px 1px #ffffff, 1px 1px 3px #333333;
user-select: none;
}
.bg {
margin-left: -2px;
margin-top: -2px;
width: 33px;
height: 33px;
background: none;
border: 1px solid #eecc88;
border-radius: 3px;
box-shadow: none;
}
</style>
let export varName
and can be set from outside. <script>
export let x = 0;
export let y = 0;
export let bg = false;
</script>
<div class={bg?"bg":""} style="left: {4+x*34}px; top: {4+y*34}px"></div>
<style>/* see full source file for styles */</style>
I came up with the idea to misuse the square and also draw the grid out of squares, but render the squares in a different way. To distinguish the styles of the square, we have another property callled bg
.
By the way, this is the code needed to draw the squares on the right.
<Square x="1" y="1" />
<Square x="3" y="1" bg="true" />
<Square x="5" y="1" />
<Square x="5" y="1" bg="true" />
Some decision were quickly made by myself back then, one of them was to go with fixed pixel sizes of the square which might be a drawback when it scomes to portability to mobile devices and stuff like that.
Fields»
The next larger unit is then the Field component. The source is just as simple as the square if not easier. It’s just a for each loop over all items from our field datastructure prepared by fieldForView(field)
.
You can have a look at the source code of
field.svelte
<script>
import { fieldForView } from './field.js';
import Square from './square.svelte';
export let field;
export let bg = false;
</script>
<div>
{#if field}
{#each fieldForView(field) as item (item.index)}
{#if item.value != 0}
<Square bg={bg} x={item.coords.x} y={item.coords.y}></Square>
{/if}
{/each}
{/if}
</div>
<style>
div {
position: relative;
top: 7px;
left: 7px;
user-select: none;
}
</style>
<script>
const gridField = createSquareField(5, 1);
const tileField = getRandomTileField();
let field = createSquareField(5, 0);
field = addTileToField(tileField, field, point(1,1));
</script>
<Field field={gridField} bg={true} />
<Field field={field} />
Dragfield»
In Svelte there’s no component inheritance, which is an advantage. So we go with composition. This means to make a field component draggable, we compose a dragfield component which conists an instance of the field component. We can easily pass down the {field}
property.
The
dragfield.svelte
<script>
import { createEventDispatcher, onMount } from 'svelte';
import { minus, point } from '$lib/utils/path-utils.js';
import { game } from './stores';
import Field from './field.svelte';
const dispatch = createEventDispatcher();
export let field;
export let spot;
let position = '';
let dragged = false;
let touch;
let doc;
$: spotPosition =
"margin-left: "+(field.size.width*-17)+"px; " +
"margin-top: "+(field.size.height*-17)+"px; " +
"width: "+(field.size.width*34)+"px; " +
"height: "+(field.size.height*34)+"px; "
;
$: spotClass = "spot-" + (dragged ? "dragged" : spot);
const correctPointToScreen = (p) => {
const {top, left} = $game.getBoundingClientRect();
p = minus(p, point(left, top));
p = minus(p, point(5, 5 + 75));
return p;
};
const pointFromEvent = (e) => correctPointToScreen(
point(e.clientX, e.clientY)
);
const addHandler = () => {
if (touch) {
doc.addEventListener('touchmove', touchMoveHandler);
doc.addEventListener('touchend', touchEndHandler);
} else {
doc.addEventListener('mousemove', mouseMoveHandler);
doc.addEventListener('mouseup', mouseUpHandler);
}
};
const removeHandler = () => {
if (touch) {
doc.removeEventListener('touchmove', touchMoveHandler);
doc.removeEventListener('touchend', touchEndHandler);
} else {
doc.removeEventListener('mouseMove', mouseMoveHandler);
doc.removeEventListener('mouseup', mouseUpHandler);
}
};
$: doc
&& (dragged
? addHandler()
: removeHandler()
);
onMount(() => {
doc = document;
return () => doc && removeHandler();
});
const touchStartHandler = (e) => downHandler(pointFromEvent(e.changedTouches[0]), true);
const mouseDownHandler = (e) => downHandler(pointFromEvent(e));
const downHandler = (pos, isTouch) => {
touch = isTouch
dragged = true;
position = "transform: translate("+pos.x+"px, "+pos.y+"px) scale(1.0);";
};
const touchEndHandler = (e) => upHandler(pointFromEvent(e.changedTouches[0]));
const mouseUpHandler = (e) => upHandler(pointFromEvent(e));
const upHandler = (pos) => {
if (dragged) {
dragged = false;
position = "";
const p = { x: pos.x + field.size.width*-17 + 12, y: pos.y + field.size.height*-17 + 12 - 39};
const d = { x: p.x/34 >>> 0, y: p.y/34 >>> 0, spot};
dispatch('drop', d);
}
};
const touchMoveHandler = (e) => moveHandler(pointFromEvent(e.changedTouches[0]));
const mouseMoveHandler = (e) => moveHandler(pointFromEvent(e));
const moveHandler = (pos) => {
if (dragged) {
position = "transform: translate("+pos.x+"px, "+pos.y+"px) scale(1.0); transition: none;";
}
};
</script>
<div
role="button"
tabindex="0"
aria-label="Drag starter"
class="spot {spotClass}"
style="{spotPosition} {position}"
on:touchstart={touchStartHandler}
on:mousedown|preventDefault={mouseDownHandler}
>
<Field {field} />
</div>
<style>
div {
position: absolute;
top: 0;
left: 0;
transform-origin: center center;
user-select: none;
transition: 0.1s ease-in-out;
}
.spot {
touch-action: none;
}
.spot-0 { transform: translate(56px, 458px) scale(0.5); }
.spot-1 { transform: translate(178px, 458px) scale(0.5); }
.spot-2 { transform: translate(297px, 458px) scale(0.5); }
.spot-dragged { transform: none; }
</style>
<div
class="{spotClass}"
style="{spotPosition} {position}"
on:touchstart={touchStartHandler}
on:mousedown|preventDefault={mouseDownHandler}
>
<Field {field} />
</div>
The more complicated parts of the file are about event listener registration and the positioning. On drag start, we lift the tile visually above the cursor or your finger when talking about touch devices, so the user can still see what tile he’s currently handling.
We register drag move and drag end events, not using Svelte`s on:mouseup
/ on:touchend
event syntax. I’ll dedicate another blog post why that. We also do some coordinate fixing to the position of the playing field at the screen.
And finally we fire a custom drop event to be handled in our main application.
If we look ahead at how the Dragfield component is used, we see that it has also a spot
property in addition to field
, which indicates the actual number of the three random tiles we are dealing with.
{#each randomTiles as tile, index}
{#if tile}
<DragField spot={index} field={tile} on:drop={dropHandler}></DragField>
{/if}
{/each}
Main»
The code above is already part of the
main.svelte
<script>
import { onMount } from 'svelte';
import { nums } from '$lib/utils/math-utils.js';
import { or } from '$lib/utils/bit-utils.js';
import { game } from './stores.js';
import { getRandomTile } from './tiles.js';
import { convertTextToField } from './conversion.js';
import {
createSquareField,
tileFitsIntoFieldWithOffset,
tileFitsIntoField,
addTileToField,
removeTileFromField,
getFullLines,
numSquares
} from './field.js';
import DragField from './dragfield.svelte';
import Field from './field.svelte';
const a123 = nums(3);
const size = 10;
const bgField = createSquareField(size, 1);
let field = createSquareField(size, 0);
let points = 0;
let record = 0;
let somethingFits = true;
let randomTiles = [];
const getRandomTileField = () => convertTextToField(getRandomTile());
const getThreeNewRandomTilesFields = () => randomTiles = a123.map(getRandomTileField);
getThreeNewRandomTilesFields();
const randomTileLeft = () => randomTiles.reduce((ret, tile) => tile!==false || ret, false);
const removeLines = () => {
const linesField = getFullLines(field);
points = points + numSquares(linesField);
removeTileFromField(linesField, field, {x:0, y:0});
};
const checkIfSomethingFits = () => {
somethingFits = randomTiles
.filter(tile => tile !== false)
.map(tile => tileFitsIntoField(tile, field))
.reduce(or, 0)
;
};
const dropHandler = (e) => {
const tile = randomTiles[e.detail.spot];
const offset = {x: e.detail.x, y: e.detail.y};
const fits = tileFitsIntoFieldWithOffset(tile, field, offset);
if (fits) {
points = points + numSquares(tile);
if (points > record) {
record = points;
localStorage.setItem('block-puzzle-record', record);
}
addTileToField(tile, field, offset);
field = field;
randomTiles[e.detail.spot] = false;
removeLines();
if (!randomTileLeft()) {
getThreeNewRandomTilesFields();
}
checkIfSomethingFits();
}
}
const restart = () => {
field = createSquareField(size, 0);
points = 0;
getThreeNewRandomTilesFields();
checkIfSomethingFits();
};
onMount(() => {
record = localStorage.getItem('block-puzzle-record');
window.oncontextmenu = (e) => {
e.preventDefault();
e.stopPropagation();
return false;
};
return () => {
window.oncontextmenu = null;
};
});
</script>
<div class="game" bind:this={$game}>
<div class="area area-record">Your record: <strong>{record !== null ? record : 0}</strong></div>
<div class="area area-points">Your points: <strong>{points}</strong></div>
<div class="area area-field">
<Field field={bgField} bg={true}></Field>
<Field field={field}></Field>
</div>
{#each a123 as item, spot}
<div id="spot{spot}" class="area area-tile"></div>
{/each}
{#each randomTiles as tile, index}
{#if tile}
<DragField spot={index} field={tile} on:drop={dropHandler}></DragField>
{/if}
{/each}
{#if somethingFits===false}
<div class="game-over">
game over
<button type="button" on:click={restart}>Play again</button>
</div>
{/if}
</div>
<style>
.game {
position: relative;
user-select: none;
}
.game-over {
position: absolute;
top: 200px;
left: 80px;
width: 200px;
text-align: center;
background: #553322;
border: 3px solid #fefbe9;
color: #eecc88;
text-transform: uppercase;
padding: 10px;
box-sizing: border-box;
}
.game-over button {
font-size: 12px;
text-transform: uppercase;
border: none;
background: transparent;
color: #eecc88;
}
.area {
position: relative;
background: #553322;
}
.area-record,
.area-points {
display: inline-block;
color: #eecc88;
font-size: 12px;
line-height: 35px;
text-indent: 10px;
text-transform: uppercase;
width: 177px;
height: 35px;
position: relative;
}
.area-points {
width: 176px;
}
.area-record strong,
.area-points strong {
font-size: 18px;
font-weight: bold;
position: absolute;
right: 10px;
}
.area-field {
margin-top: 4px;
width: 359px;
height: 359px;
}
.area-tile {
display: inline-block;
margin-top: 4px;
margin-right: 4px;
width: 117px;
height: 117px;
}
#spot2 {
margin-right: 0;
}
</style>
<script>
import { game } from './stores';
</script>
<div class="game" bind:this={$game}>
</div>
Next we split the game into a lot of areas with a dark brown background color. All the {values}
magically update when needed, the already mentioned two way data binding. Our <Field>
component will also update as soon as there are changes to {field}
.
<div class="area area-points">Your record: <strong>{record !== null ? record : 0}</strong></div>
<div class="area area-points">Your points: <strong>{points}</strong></div>
<div class="area area-field">
<Field field={bgField} bg={true}></Field>
<Field field={field}></Field>
</div>
{#each a123 as item, spot}
<div id="spot{spot}" class="area area-tile"></div>
{/each}
The part about the three random tiles which become rendered as <DragField>
components was shown a little further up. Here’s the part of the logic, a method to fill up with three random tile spots again and again. Remember, Svelte is taking care to update the UI whenever the content of randomTiles
changes.
The last method randomTileLeft()
detects, whether we need to come up with three new tiles.
let randomTiles = [];
const getRandomTileField = () =>
convertTextToField(getRandomTile());
const getThreeNewRandomTilesFields = () =>
randomTiles = a123.map(getRandomTileField);
getThreeNewRandomTilesFields();
const randomTileLeft = () => randomTiles.reduce((ret, tile) => tile!==false || ret, false);
Then we have some more game logic, a method to remove all filled up lines. Please notice the points you get for all disappearing squares.
const removeLines = () => {
const linesField = getFullLines(field);
points = points + numSquares(linesField);
removeTileFromField(linesField, field, {x:0, y:0});
};
Then we have a function checking whether one of the remaining random tiles would still fit the field. At the time of writing this, I would probably refactor this method into the field.js file. Also this implementation has a side effect, changing the somethingFits
variable. We’ll come back to this soon.
const checkIfSomethingFits = () => {
somethingFits = randomTiles
.filter(tile => tile !== false)
.map(tile => tileFitsIntoField(tile, field))
.reduce(or, 0)
;
};
Now the core of main, the handler for our custom drop Event from the three DragField instances. Whenever we drop a tile field over the playing field, we do quite a bunch of tasks if it fits.
- earn points and as a bonus we update the record, which is basically the users highest score stored in his browsers localStorage.
- add the tile to the
{field}
so that the UI will update - remove it from the remaining random tiles
- check for fully set lines and remove those
- get three new random tiles, if needed
- and check the remaining random tiles if one of those fit the field
What a bunch of tasks, but still quite readable, don’t you think so?
const dropHandler = (e) => {
const tile = randomTiles[e.detail.spot];
const offset = {x: e.detail.x, y: e.detail.y};
const fits = tileFitsIntoFieldWithOffset(tile, field, offset);
if (fits) {
points = points + numSquares(tile);
if (points > record) {
record = points;
localStorage.setItem('block-puzzle-record', record);
}
addTileToField(tile, field, offset);
field = field;
randomTiles[e.detail.spot] = false;
removeLines();
if (!randomTileLeft()) {
getThreeNewRandomTilesFields();
}
checkIfSomethingFits();
}
}
When {somethingFits}
turns false, the game is over. The UI magically renders a notice.
{#if somethingFits===false}
<div class="game-over">
game over
<button type="button" on:click={restart}>Play again</button>
</div>
{/if}
But don’t be sad, you can just start again, just by a click. For that we do
- reset the field
- start counting points at zero again
- get three new random tiles
- and check if those could fit the field, the latter to let the game over notice disappear
const restart = () => {
field = createSquareField(size, 0);
points = 0;
getThreeNewRandomTilesFields();
checkIfSomethingFits();
};
Sources»
That’s it, I think we are done reading and maybe eplaining some of the code. I hope you enjoyed. You can either skip back and play the game or you can have a look at the sources. I also made an updated REPL if you like that more.
blocks.svelte
<script>
import { point } from '$lib/utils/path-utils.js';
import { getTile } from './tiles.js';
import { convertTextToField } from './conversion.js';
import { createField, addTileToField } from './field.js';
import Field from './field.svelte';
let bgField = createField(16, 16, 1);
let field = createField(16, 16, 0);
field = addTileToField(convertTextToField(getTile(0)), field, point(7,8)); // 3x3
field = addTileToField(convertTextToField(getTile(1)), field, point(11,8)); // 2x2
field = addTileToField(convertTextToField(getTile(10)), field, point(14,8)); // 1x1
field = addTileToField(convertTextToField(getTile(2)), field, point(1,1)); // L nw
field = addTileToField(convertTextToField(getTile(3)), field, point(5,1)); // L ne
field = addTileToField(convertTextToField(getTile(4)), field, point(5,5)); // L se
field = addTileToField(convertTextToField(getTile(5)), field, point(1,5)); // L sw
field = addTileToField(convertTextToField(getTile(6)), field, point(4,10)); // l se
field = addTileToField(convertTextToField(getTile(7)), field, point(1,13)); // l nw
field = addTileToField(convertTextToField(getTile(8)), field, point(1,10)); // l ne
field = addTileToField(convertTextToField(getTile(9)), field, point(4,13)); // l sw
field = addTileToField(convertTextToField(getTile(18)), field, point(10,1)); // 1x5
field = addTileToField(convertTextToField(getTile(16)), field, point(11,3)); // 1x4
field = addTileToField(convertTextToField(getTile(14)), field, point(12,5)); // 1x3
field = addTileToField(convertTextToField(getTile(12)), field, point(9,5)); // 1x2
field = addTileToField(convertTextToField(getTile(17)), field, point(14,10)); // 5x1
field = addTileToField(convertTextToField(getTile(15)), field, point(12,11)); // 4x1
field = addTileToField(convertTextToField(getTile(13)), field, point(10,12)); // 3x1
field = addTileToField(convertTextToField(getTile(11)), field, point(8,13)); // 2x1
</script>
<div>
<Field bg={true} field={bgField}></Field>
<Field field={field}></Field>
</div>
<style>
div {
position: relative;
width: 560px;
height: 560px;
background: #553322;
zoom: 0.44;
}
</style>
blockviewer.svelte
<script>
import { point } from '$lib/utils/path-utils.js';
import { getTile, getTiles } from './tiles.js';
import { convertTextToField } from './conversion.js';
import { createField, addTileToField, convertFieldTo2dArray } from './field.js';
import Field from './field.svelte';
import Prism from "$lib/shared/prism.svelte";
import RangeSlider from '$lib/shared/range-slider.svelte';
let bgField = createField(5, 5, 1);
let num = 5;
$: tile = getTile(num);
$: tileField = convertTextToField(tile);
$: fgField = addTileToField(tileField, createField(5, 5, 0), point(0,0));
$: field = stringify(tileField, replacer, 2);
$: field2d = stringify(convertFieldTo2dArray(tileField), replacer, 2);
$: tileJs = `const tile = \`\n${tile}\`;${"\n ".repeat(6 - tile.split("\n").length)}`;
$: fieldJs = `const field = ${field}\;`;
$: field2dJs = `const field2d = ${field2d}\;${"\n ".repeat(7 - field2d.split("\n").length)}`;
const replacer = (key,value) => value instanceof Array
? JSON.stringify(value)
: value
;
const stringify = (obj) => JSON.stringify(obj, replacer, 2)
.replace(/"\[/g, '[')
.replace(/\]"/g, ']')
.replace(/\\"/g, '"')
.replace(/""/g, '"')
.replace(/\[\[/g, '\[\n \[')
.replace(/\],\[/g, '\],\n \[')
.replace(/\]\]/g, '\]\n\]')
;
</script>
<RangeSlider label="tile" bind:value={num} min={0} max={getTiles().length - 1} />
<section>
<div class="code">
<Prism lang="javascript">{tileJs}</Prism>
</div>
<div class="code">
<Prism lang="javascript">{fieldJs}</Prism>
</div>
<br>
<div class="code">
<Prism lang="javascript">{field2dJs}</Prism>
</div>
<div class="field">
<Field bg={true} field={bgField}></Field>
<Field field={fgField}></Field>
</div>
</section>
<style>
.field {
vertical-align: top;
display: inline-block;
position: relative;
width: 190px;
height: 190px;
background: #553322;
margin-top: 9px;
}
.code {
vertical-align: top;
display: inline-block;
margin-right: 18px;
}
.code :global(pre) {
min-width: 220px;
}
@media only screen and (max-width: 600px) {
div {
zoom: 0.75;
}
}
</style>
conversion.js
import { nums } from "$lib/utils/math-utils";
import { objectFlip } from "$lib/utils/object-utils";
const LINEBREAK = "\n";
const EMPTY = " ";
const SQUARE = "#";
const valueCharMapping = {
0: EMPTY,
1: SQUARE
};
const convertValueToChar = (val) => valueCharMapping[val];
const convertCharToValue = (text) => parseInt(objectFlip(valueCharMapping)[text]);
const addLinebreakAtWidth = (position, width) => position % width == 0 ? LINEBREAK : "";
const returnMaxLength = (maxLength, line) => maxLength > line.length ? maxLength : line.length;
const fillUpWithEmptyTillLength = (line, length) => line + getEmpties(length - line.length);
const getEmpties = num => nums(num).fill(EMPTY).join('');
export const convertFieldToText = (field) =>
field.values.map((item, index) =>
convertValueToChar(item)
+ addLinebreakAtWidth(index+1, field.size.width)
).join("");
export const convertTextToField = (text) => {
const lines = text.split(LINEBREAK);
const width = lines.reduce(returnMaxLength, 0);
const height = lines.length;
text = lines.map(line => fillUpWithEmptyTillLength(line, width)).join('');
return {
size: { width, height },
values: text.split('').map(convertCharToValue)
};
};
dragfield.svelte
<script>
import { createEventDispatcher, onMount } from 'svelte';
import { minus, point } from '$lib/utils/path-utils.js';
import { game } from './stores';
import Field from './field.svelte';
const dispatch = createEventDispatcher();
export let field;
export let spot;
let position = '';
let dragged = false;
let touch;
let doc;
$: spotPosition =
"margin-left: "+(field.size.width*-17)+"px; " +
"margin-top: "+(field.size.height*-17)+"px; " +
"width: "+(field.size.width*34)+"px; " +
"height: "+(field.size.height*34)+"px; "
;
$: spotClass = "spot-" + (dragged ? "dragged" : spot);
const correctPointToScreen = (p) => {
const {top, left} = $game.getBoundingClientRect();
p = minus(p, point(left, top));
p = minus(p, point(5, 5 + 75));
return p;
};
const pointFromEvent = (e) => correctPointToScreen(
point(e.clientX, e.clientY)
);
const addHandler = () => {
if (touch) {
doc.addEventListener('touchmove', touchMoveHandler);
doc.addEventListener('touchend', touchEndHandler);
} else {
doc.addEventListener('mousemove', mouseMoveHandler);
doc.addEventListener('mouseup', mouseUpHandler);
}
};
const removeHandler = () => {
if (touch) {
doc.removeEventListener('touchmove', touchMoveHandler);
doc.removeEventListener('touchend', touchEndHandler);
} else {
doc.removeEventListener('mouseMove', mouseMoveHandler);
doc.removeEventListener('mouseup', mouseUpHandler);
}
};
$: doc
&& (dragged
? addHandler()
: removeHandler()
);
onMount(() => {
doc = document;
return () => doc && removeHandler();
});
const touchStartHandler = (e) => downHandler(pointFromEvent(e.changedTouches[0]), true);
const mouseDownHandler = (e) => downHandler(pointFromEvent(e));
const downHandler = (pos, isTouch) => {
touch = isTouch
dragged = true;
position = "transform: translate("+pos.x+"px, "+pos.y+"px) scale(1.0);";
};
const touchEndHandler = (e) => upHandler(pointFromEvent(e.changedTouches[0]));
const mouseUpHandler = (e) => upHandler(pointFromEvent(e));
const upHandler = (pos) => {
if (dragged) {
dragged = false;
position = "";
const p = { x: pos.x + field.size.width*-17 + 12, y: pos.y + field.size.height*-17 + 12 - 39};
const d = { x: p.x/34 >>> 0, y: p.y/34 >>> 0, spot};
dispatch('drop', d);
}
};
const touchMoveHandler = (e) => moveHandler(pointFromEvent(e.changedTouches[0]));
const mouseMoveHandler = (e) => moveHandler(pointFromEvent(e));
const moveHandler = (pos) => {
if (dragged) {
position = "transform: translate("+pos.x+"px, "+pos.y+"px) scale(1.0); transition: none;";
}
};
</script>
<div
role="button"
tabindex="0"
aria-label="Drag starter"
class="spot {spotClass}"
style="{spotPosition} {position}"
on:touchstart={touchStartHandler}
on:mousedown|preventDefault={mouseDownHandler}
>
<Field {field} />
</div>
<style>
div {
position: absolute;
top: 0;
left: 0;
transform-origin: center center;
user-select: none;
transition: 0.1s ease-in-out;
}
.spot {
touch-action: none;
}
.spot-0 { transform: translate(56px, 458px) scale(0.5); }
.spot-1 { transform: translate(178px, 458px) scale(0.5); }
.spot-2 { transform: translate(297px, 458px) scale(0.5); }
.spot-dragged { transform: none; }
</style>
field.js
import { and, notZero, or } from "$lib/utils/bit-utils";
import { nums } from "$lib/utils/math-utils";
import { plus } from "$lib/utils/path-utils";
export const createField = (columns, rows, value) => ({
size: {
width: columns,
height: rows
},
values: nums(columns*rows).fill(value)
});
export const createSquareField = (size, value) => createField(size, size, value);
const coordsAndSizeToIndex = (coords, size) => coords.y * size.width + coords.x;
const indexAndSizeToCoords = (index, size) => ({
x: index % size.width,
y: index / size.width >>> 0
});
export const addTileToField = (tile, field, offset) => {
tile.values.forEach( (value, index) => {
if (value !== 0) {
const coords = plus(indexAndSizeToCoords(index, tile.size), offset);
field.values[coordsAndSizeToIndex(coords, field.size)] = value;
}
});
return field;
};
export const removeTileFromField = (tile, field, offset) => {
tile.values.forEach( (value, index) => {
if (value !== 0) {
const coords = plus(indexAndSizeToCoords(index, tile.size), offset);
field.values[coordsAndSizeToIndex(coords, field.size)] = 0;
}
});
return field;
};
export const tileFitsIntoFieldWithOffset = (tile, field, offset) => {
let doesFit = true;
if (offset.x + tile.size.width > field.size.width || offset.y + tile.size.height > field.size.height) {
doesFit = false;
} else {
tile.values.forEach( (value, index) => {
if (value !== 0) {
const coords = plus(indexAndSizeToCoords(index, tile.size), offset);
if (field.values[coordsAndSizeToIndex(coords, field.size)] !== 0) {
doesFit = false;
}
}
});
}
return doesFit;
};
export const tileFitsIntoField = (tile, field) => {
return nums(field.size.width * field.size.height)
.map(index => tileFitsIntoFieldWithOffset(tile, field, indexAndSizeToCoords(index, field.size)))
.reduce(or, 0);
};
const getColumn = (field, column) => {
return {
size: {
width: 1,
height: field.size.height
},
values: field.values.filter((item, index) => index % field.size.width === column)
};
};
const getRow = (field, row) => {
const start = row * field.size.width;
const end = start + field.size.width;
return {
size: {
width: field.size.width,
height: 1
},
values: field.values.slice(start, end)
};
};
export const getFullLines = (field) => {
let linesField = createField(field.size.width, field.size.height, 0);
nums(field.size.height).forEach(r => {
const row = getRow(field, r);
if (row.values.reduce(and, 1)===1) {
addTileToField(row, linesField, {x:0, y:r});
}
});
nums(field.size.width).forEach(c => {
const column = getColumn(field, c);
if (column.values.reduce(and, 1)===1) {
addTileToField(column, linesField, {x:c, y:0});
}
});
return linesField;
};
export const numSquares = (field) => field.values.filter(notZero).length;
export const convertFieldTo2dArray = function (field) {
let arr = [];
field.values.forEach((value, index) => {
let coords = indexAndSizeToCoords(index, field.size);
if (!Array.isArray(arr[coords.x])) {
arr[coords.x] = [];
};
arr[coords.x][coords.y] = value;
});
return arr;
};
export const fieldForView = (field) => field.values.map((value, index) => ({
index: index,
value: value,
coords: indexAndSizeToCoords(index, field.size)
}));
field.svelte
<script>
import { fieldForView } from './field.js';
import Square from './square.svelte';
export let field;
export let bg = false;
</script>
<div>
{#if field}
{#each fieldForView(field) as item (item.index)}
{#if item.value != 0}
<Square bg={bg} x={item.coords.x} y={item.coords.y}></Square>
{/if}
{/each}
{/if}
</div>
<style>
div {
position: relative;
top: 7px;
left: 7px;
user-select: none;
}
</style>
main.svelte
<script>
import { onMount } from 'svelte';
import { nums } from '$lib/utils/math-utils.js';
import { or } from '$lib/utils/bit-utils.js';
import { game } from './stores.js';
import { getRandomTile } from './tiles.js';
import { convertTextToField } from './conversion.js';
import {
createSquareField,
tileFitsIntoFieldWithOffset,
tileFitsIntoField,
addTileToField,
removeTileFromField,
getFullLines,
numSquares
} from './field.js';
import DragField from './dragfield.svelte';
import Field from './field.svelte';
const a123 = nums(3);
const size = 10;
const bgField = createSquareField(size, 1);
let field = createSquareField(size, 0);
let points = 0;
let record = 0;
let somethingFits = true;
let randomTiles = [];
const getRandomTileField = () => convertTextToField(getRandomTile());
const getThreeNewRandomTilesFields = () => randomTiles = a123.map(getRandomTileField);
getThreeNewRandomTilesFields();
const randomTileLeft = () => randomTiles.reduce((ret, tile) => tile!==false || ret, false);
const removeLines = () => {
const linesField = getFullLines(field);
points = points + numSquares(linesField);
removeTileFromField(linesField, field, {x:0, y:0});
};
const checkIfSomethingFits = () => {
somethingFits = randomTiles
.filter(tile => tile !== false)
.map(tile => tileFitsIntoField(tile, field))
.reduce(or, 0)
;
};
const dropHandler = (e) => {
const tile = randomTiles[e.detail.spot];
const offset = {x: e.detail.x, y: e.detail.y};
const fits = tileFitsIntoFieldWithOffset(tile, field, offset);
if (fits) {
points = points + numSquares(tile);
if (points > record) {
record = points;
localStorage.setItem('block-puzzle-record', record);
}
addTileToField(tile, field, offset);
field = field;
randomTiles[e.detail.spot] = false;
removeLines();
if (!randomTileLeft()) {
getThreeNewRandomTilesFields();
}
checkIfSomethingFits();
}
}
const restart = () => {
field = createSquareField(size, 0);
points = 0;
getThreeNewRandomTilesFields();
checkIfSomethingFits();
};
onMount(() => {
record = localStorage.getItem('block-puzzle-record');
window.oncontextmenu = (e) => {
e.preventDefault();
e.stopPropagation();
return false;
};
return () => {
window.oncontextmenu = null;
};
});
</script>
<div class="game" bind:this={$game}>
<div class="area area-record">Your record: <strong>{record !== null ? record : 0}</strong></div>
<div class="area area-points">Your points: <strong>{points}</strong></div>
<div class="area area-field">
<Field field={bgField} bg={true}></Field>
<Field field={field}></Field>
</div>
{#each a123 as item, spot}
<div id="spot{spot}" class="area area-tile"></div>
{/each}
{#each randomTiles as tile, index}
{#if tile}
<DragField spot={index} field={tile} on:drop={dropHandler}></DragField>
{/if}
{/each}
{#if somethingFits===false}
<div class="game-over">
game over
<button type="button" on:click={restart}>Play again</button>
</div>
{/if}
</div>
<style>
.game {
position: relative;
user-select: none;
}
.game-over {
position: absolute;
top: 200px;
left: 80px;
width: 200px;
text-align: center;
background: #553322;
border: 3px solid #fefbe9;
color: #eecc88;
text-transform: uppercase;
padding: 10px;
box-sizing: border-box;
}
.game-over button {
font-size: 12px;
text-transform: uppercase;
border: none;
background: transparent;
color: #eecc88;
}
.area {
position: relative;
background: #553322;
}
.area-record,
.area-points {
display: inline-block;
color: #eecc88;
font-size: 12px;
line-height: 35px;
text-indent: 10px;
text-transform: uppercase;
width: 177px;
height: 35px;
position: relative;
}
.area-points {
width: 176px;
}
.area-record strong,
.area-points strong {
font-size: 18px;
font-weight: bold;
position: absolute;
right: 10px;
}
.area-field {
margin-top: 4px;
width: 359px;
height: 359px;
}
.area-tile {
display: inline-block;
margin-top: 4px;
margin-right: 4px;
width: 117px;
height: 117px;
}
#spot2 {
margin-right: 0;
}
</style>
square.svelte
<script>
export let x = 0;
export let y = 0;
export let bg = false;
</script>
<div class={bg?"bg":""} style="left: {4+x*34}px; top: {4+y*34}px"></div>
<style>
div {
position: absolute;
width: 30px;
height: 30px;
background: #eecc88;
border-radius: 3px;
box-shadow: inset 1px 1px 1px #ffffff, 1px 1px 3px #333333;
user-select: none;
}
.bg {
margin-left: -2px;
margin-top: -2px;
width: 33px;
height: 33px;
background: none;
border: 1px solid #eecc88;
border-radius: 3px;
box-shadow: none;
}
</style>
stores.js
import { writable } from 'svelte/store';
export const game = writable(undefined);
tiles.js
const tiles = [
`
###
###
###`,
`
##
##`,
`
#
#
###`,
`
#
#
###`,
`
###
#
# `,
`
###
#
#`,
`
#
##`,
`
##
#`,
`
#
##`,
`
##
# `,
`
#`,
`
#
#`,
`
##`,
`
#
#
#`,
`
###`,
`
#
#
#
#`,
`
####`,
`
#
#
#
#
#`,
`
#####`
].map(tile => tile.replace(/^\n/, ""));
export const getTile = (i) => tiles[i<tiles.length?i:0];
export const getTiles = () => tiles;
export const getRandomTile = () =>
tiles[Math.floor(Math.random() * tiles.length)];