I'm currently working on an e-learning portal for a client, where registered students can take tests based on the course data configured by the admin through a dedicated admin panel. One of the requirements was a configurable image puzzle - the admin can upload an image which will be split into pieces which would eventually be presented to the students on a slide. They can then drag and drop the pieces to finish the puzzle.
I'll try to briefly outline the steps I took to achieve the same. So, let's get started!
Pre-requisites:
- Basic Knowledge of Laravel.
- Basic knowledge of Javascript.
- Little bit of CSS.
We're going to use a fresh Laravel project created by following the usual steps as per the documentation.
To keep things short, we're going to stick to the default welcome page that comes with the installation.
Route::view('/', 'welcome')->name('welcome');
All our backend processing will be done in the routes file, using Closures.
Setting up the Image Upload Form
We'll get rid of the existing markup on the welcome page and start with a blank slate - Our app consists of an image upload form with a single file input and the puzzle will be displayed right below the form, so when the user uploads an image, we process it on the backend and redirect the user back to the same page with the puzzle ready to be solved just below the form.
<form action="{{ route('upload') }}" method="POST" enctype="multipart/form-data">
@csrf
<input type="file" name="image" required/>
<button type="submit">Upload</button>
</form>
and some basic styling to go with it:
body {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
form {
display: flex;
align-items: center;
}
button {
padding: .5rem;
background: cornflowerblue;
color: white;
margin: .5rem;
}
The form points to an 'upload' route (which we'll work on shortly). Important to note here that the enctype attribute for the form should be "multipart/form-data" since we're using a file input.
Handling the file upload and processing the image
Next up, we head over to the web.php file in the routes folder where all our web routes are defined. We add the following route definition where we'll handle the file upload and perform the required processing.
Route::post('upload', function(Request $request){
// handle file upload...
});
The closure receives an object of the Illuminate\Http\Request class which, as per the offical docs,
provides an object-oriented way to interact with the current HTTP request being handled by your application as well retrieve the input, cookies, and files that were submitted with the request.
Using the $request object, we can easily retrieve the file like so:
$uploadedFile = $request->file('image')
// The name 'image' corresponds to the name attribute we've given to the file input on our upload form.
The idea here is to temporarily store the image and crop out pieces from it and store them separately so that we can later shuffle and display them in a 3x3 grid. For this purpose, we're going to use Intervention Image which is an open source PHP image handling and manipulation library. It can be installed in our Laravel project using composer. There are certain system requirements that should be met in order for it to work. You can check them out on their official website.
Assuming all requirements are met and the library has been successfully installed in our project, we can now use the ImageManagerStatic class provided by the library which has a bunch of static functions that can be used to perform all sorts of image manipulation like resize, crop, fit etc. We're going to use few of them as per our needs.
In the routes file, we can import this class like so:
use Intervention\Image\ImageManagerStatic as Image;
Scaling down large images
We don't want our puzzle to blow up all over the screen in case the user uploads a large image, so we scale down the image if it's width/height exceeds a certain threshold. For this example, we'll set it up at 500 pixels. So, we squeeze the image using the fit() method on the above mentioned class and save the file temporarily for further processing.
$image = Image::make($request->file('image'));
$extension = $request->file('image')->getClientOriginalExtension();
$height = $image->height();
$width = $image->width();
if($width > 500) {
$resized = Image::make($request->file('image'))->fit(500);
} else {
$resized = Image::make($request->file('image'));
}
$resized->save('tmp.' . $extension);
As per the docs, the fit() method:
Combine cropping and resizing to format image in a smart way. The method will find the best fitting aspect ratio of your given width and height on the current image automatically, cut it out and resize it to the given dimension.
Calculating the height and width of individual puzzle pieces
We can do this by getting the width & height of the scaled down image and dividing it by 3 (since our puzzle is a 3x3 grid, each piece takes up 1/3 the total width and height respectively.
$resizedImg = Image::make('tmp.' . $extension);
$height = $resizedImg->height();
$width = $resizedImg->width();
$puzzlePieceHeight = $height / 3;
$puzzlePieceWidth = $width / 3;
Once this is done, we need to crop out the individual pieces from the image and save each piece separately.
The image library has a crop() method:
Cut out a rectangular part of the current image with given width and height. Define optional x,y coordinates to move the top-left corner of the cutout to a certain position.
In our case the width and height will be the width and height of each piece as calculated above. The x and y coordinates need to be generated depending on which part of the puzzle the piece represents. I created a little visualization to help you understand:
This can be achieved using 2 nested for loops like so:
for ($y=0; $y <=2 ; $y++) {
for ($x=0; $x <= 2; $x++) {
$xOffset = ceil($puzzlePieceWidth * $x);
$yOffset = ceil($puzzlePieceHeight * $y);
}
}
This will generate the x and y offsets that we need to pass to the crop method. Note that we're using the ceil() function to round off the coordinates up to the nearest integer since the crop method only accepts integer coordinates.
Within the inner for loop, we perform the crop operation and store the cropped out part as a separate image.
$part = 1; // Will be used to number the parts
$images = collect([]); // Will be passed to the view to display the uploaded images
for ($y=0; $y <=2 ; $y++) {
for ($x=0; $x <= 2; $x++) {
$xOffset = ceil($puzzlePieceWidth * $x);
$yOffset = ceil($puzzlePieceHeight * $y);
$partImg = Image::make('tmp.' . $extension)
->crop(
ceil($puzzlePieceWidth),
ceil($puzzlePieceHeight),
$xOffset,
$yOffset
);
$partFileName = 'part' . $part . '.' . $extension;
$partImg->save($partFileName);
$images->add([ 'image_url' => $partFileName, 'part_no' => $part++ ]);
}
}
This will save the puzzle pieces as separate images named part1, part2, part3 and so on, up until part9. All these images are stored directly in the public folder since we haven't specified any folder path, but it can be easily done with the image library. For e.g.
$partFileName = 'puzzle_pieces/part' . $part++ . '.' . $extension;
After generating the images, we can safely delete the temporary file that we created earlier. Within the loop, we're also adding the image url and the part number to an $images collection which we'll pass back to the view for displaying our puzzle.
File::delete('tmp.' . $extension);
return redirect('/')->with('images', $images);
using the with() method, the images collection is flashed to the session. We will make a small change here to our welcome page route definition - we get the data from the session and pass it on to the view.
Route::get('/', function(){
$images = Session::get('images');
return view('welcome', compact('images'));
});
Setting up the puzzle
Displaying the images
Note that the images array will be available in our view only after the redirection takes place after image upload and processing. So we need to check if its set and then display the images. So, in the welcome.blade.php file, just below our form, we add the images in a CSS grid.
@isset($images)
<div class="puzzle-area">
<h5>Solve the Puzzle using Drag n Drop!<h5>
<div id="puzzle">
@foreach($images as $img)
<img class="puzzle-piece" src="{{ asset($img['image_url']) }}" data-part-no="{{$img['part_no']}}" />
@endforeach
</div>
</div>
@endisset
The puzzle grid consists of nine images, each displaying one piece of the puzzle.
Adding some styles to it...
.puzzle-area {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
#puzzle {
margin: .5rem;
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.puzzle-piece {
border: 1px dashed green;
width: 100%;
height:100%;
}
The end result looks like below:
Setting up drag and drop functionality
For the drag and drop functionality, we're going to use an amazing library called Dragula which makes it dead simple to achieve what we want. The official website rightly says:
Drag and drop so simple it hurts
We'll use the CDN version for the purpose of this demo.
First, we'll grab the CSS in our head tag:
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/dragula/3.6.1/dragula.min.css" integrity="sha512-49xW99xceMN8dDoWaoCaXvuVMjnUctHv/jOlZxzFSMJYhqDZmSF/UnM6pLJjQu0YEBLSdO1DP0er6rUdm8/VqA==" crossorigin="anonymous" />
and the JS file at the bottom of our body tag:
<script src="https://cdnjs.cloudflare.com/ajax/libs/dragula/3.6.1/dragula.js" integrity="sha512-vfilRD4VUrkyYyVXJid7Lhr942zAkL8BaFD3s5E9zklqhpJwn5qOcK1aldTzeQ5EgPjHZHMjFPDdmt+1Xf9zzg==" crossorigin="anonymous"></script>
Note that this libary doesn't support including the script tag in the head. So, it has to be appended to the body tag.
Next up, we'll setup the drag and drop functionality on our puzzle grid using dragula:
<script>
const DragNDrop = (function(){
const winningCombination = '123456789';
function init() {
const drake = dragula([document.getElementById('puzzle')]);
drake.on('drop', checkWinningCombination);
}
function checkWinningCombination(e) {
const puzzlePieces = Array.from(document.getElementsByClassName('puzzle-piece'));
const currentCombination = puzzlePieces.map(piece => piece.getAttribute('data-part-no')).slice(0, puzzlePieces.length - 1).join('');
if(currentCombination == winningCombination) {
document.getElementById('msgWin').style.display = 'block';
}
}
return { init };
})();
window.onload = function(){
DragNDrop.init();
}
</script>
Step by step explanation for the script:
- We're using the Revealing module pattern to encapsulate all our variables and functions so that they don't pollute the global namespace.
- The module exposes a single public method called init() which does the actual setup.
- We call this function once the window is loaded using the window.onloaded event listener.
- Witin the init() method, we can add drag and drop features to our puzzle grid using the dragula API. It accepts an array of containers, which in our case, is the puzzle element.
const drake = dragula([document.getElementById('puzzle')]);
We can then listen to the 'drop' event on the resulting object.
drake.on('drop', checkWinningCombination);
- The drop event provides additional information, but we don't need it here. Instead, we have defined a string called winningCombination. On each drop of piece, we'll compare the current combination of DOM elements (order, to be more precise). When the current combination equals the winning combination, we declare that the user has solved the puzzle!.
function checkWinningCombination(e) {
const puzzlePieces = Array.from(document.getElementsByClassName('puzzle-piece'));
const currentCombination = puzzlePieces.map(piece => piece.getAttribute('data-part-no'))
.slice(0, puzzlePieces.length - 1)
.join('');
if(currentCombination == winningCombination) {
document.getElementById('msgWin').style.display = 'block';
}
}
The getElementsByClassName() function returns an HTMLCollection which doesn't have a map function on its prototype, but we can easily convert it to an Array by using Array.from().
Note: The call to .splice(0, puzzlePieces.length - 1) is because the resulting array has one extra element at the end which we don't need. So we get rid of it by splicing the array.
We then map over the resulting attribute and grab the 'data-part-no' from each element which corresponds to the puzzle piece no. The resulting array is joined to form a string.
When all the puzzle pieces are in their correct place, the resulting string will be '123456789' which we have defined as our winning combination. When both combinations match, we declare that the user has won!
Last piece of the puzzle (Pun intended!)
At the moment, our puzzle is displayed with the tiles already in their correct place, so there's no point moving the pieces around as the puzzle is already solved.
To give the user something to play around with, we can display the tiles in a random order, so that the user needs to take some effort to solve it. This can be achieved by using the shuffle() method on our images collection before passing it to our view:
return redirect('/')->with('images', $images->shuffle());
And voila! We have a full fledged puzzle image to play around with:
As an added bonus, we'll spice it up with some confetti when the user wins, so we import the canvas-confetti package at the end of our body tag:
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.3.2/dist/confetti.browser.min.js"></script>
and we simply call the confetti() function when the user wins:
if(currentCombination == winningCombination) {
document.getElementById('msgWin').style.display = 'block';
confetti();
}
Now solve the puzzle and watch that confetti light up your screen!
You can play around with the app here on Heroku.
Top comments (2)
Wow, it's impressive. I also love working with Laravel. It's is a robust MVC PHP framework designed for developers who need a simple and elegant toolkit to create full-featured web applications. A web application framework with expressive, elegant syntax - freeing you to create without sweating the small things. Many of my colleagues have also done such projects using Lavaret. One even did create play jigsaw puzzles online. It is an interactive game where you can create different types of puzzles. It deserves to be played by both children and adults.
Nice,
You can look at my own implementation in JS
Puzzle
Regards