In this tutorial, we'll create a dynamic track-building game using JavaScript and the HTML Canvas API. Our goal is to allow players to place tracks on a grid where each track piece automatically connects to adjacent pieces. This step-by-step guide will cover the essential concepts and code necessary to bring this project to life, ensuring you understand why and how it works.
Prerequisites: To follow along with this tutorial, you should have basic knowledge of HTML, CSS, and JavaScript. Familiarity with the HTML Canvas API and JavaScript event handling will be beneficial but not strictly necessary, as we'll cover these concepts throughout.
Project Overview
The game will feature:
- A grid-based layout where each cell can hold a track piece.
- Automatic connections between adjacent track pieces, forming straights and turns as needed.
- Interactive features like placing and removing tracks with mouse clicks.
Here's what we'll build:
- A grid representing the game area.
- Functionality to add, remove, and connect tracks automatically based on adjacent cells.
- Visual feedback for placing tracks, including highlighting the current cell under the mouse.
Step 1: Setting Up the Project
First, set up the basic HTML structure with a canvas element. The canvas will be our drawing surface for the grid and tracks.
index.html:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Track Building Game</title> <style> canvas { border: 1px solid black; display: block; margin: 20px auto; } </style> </head> <body> <canvas id="gameCanvas" width="800" height="800"></canvas> <script src="game.js"></script> </body> </html>
Step 2: Setting Up the Grid
We'll create a 20x20 grid using JavaScript, each cell being 40x40 pixels. We'll represent each cell as an object with properties to track connections to adjacent cells and whether it has a track piece.
game.js:
// Select the canvas and get its context const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); // Define the size of the grid and cells const gridSize = 20; // 20x20 grid cells const cellSize = 40; // Each cell is 40x40 pixels // Initialize a 2D array to represent the grid as nodes const grid = Array.from({ length: gridSize }, () => Array.from({ length: gridSize }, () => ({ connections: { top: false, right: false, bottom: false, left: false }, hasTrack: false // Indicates if the cell has a track })) ); // Track the currently highlighted cell let highlightedCell = { row: -1, col: -1 }; // Function to draw the grid on the canvas function drawGrid() { ctx.clearRect(0, 0, canvas.width, canvas.height); // Clear the canvas for (let row = 0; row < gridSize; row++) { for (let col = 0; col < gridSize; col++) { // Highlight the current cell if (highlightedCell.row === row && highlightedCell.col === col) { ctx.fillStyle = 'lightgrey'; ctx.fillRect(col * cellSize, row * cellSize, cellSize, cellSize); } // Draw each cell as a rectangle ctx.strokeStyle = 'gray'; ctx.strokeRect(col * cellSize, row * cellSize, cellSize, cellSize); } } } // Initial call to draw the grid drawGrid(); Step 3: Adding Tracks with Automatic Connections To add tracks, we'll implement click event handling on the canvas. When you click on a cell, it will add a track and automatically connect it to adjacent tracks if they exist. // Function to convert canvas coordinates to grid coordinates function getGridPosition(x, y) { return { row: Math.floor(y / cellSize), col: Math.floor(x / cellSize) }; } // Function to add a track and update connections automatically function addTrack(row, col) { // Check bounds if (row < 0 || row >= gridSize || col < 0 || col >= gridSize) return; const node = grid[row][col]; // Place a track and update the node node.hasTrack = true; // Update connections with adjacent cells updateConnections(row, col); // Redraw the grid and tracks drawGrid(); drawTracks(); } // Update connections with adjacent cells function updateConnections(row, col) { const node = grid[row][col]; // Check and connect with top neighbor if (row > 0 && grid[row - 1][col].hasTrack) { node.connections.top = true; grid[row - 1][col].connections.bottom = true; } // Check and connect with right neighbor if (col < gridSize - 1 && grid[row][col + 1].hasTrack) { node.connections.right = true; grid[row][col + 1].connections.left = true; } // Check and connect with bottom neighbor if (row < gridSize - 1 && grid[row + 1][col].hasTrack) { node.connections.bottom = true; grid[row + 1][col].connections.top = true; } // Check and connect with left neighbor if (col > 0 && grid[row][col - 1].hasTrack) { node.connections.left = true; grid[row][col - 1].connections.right = true; } }
Step 4: Drawing Tracks Based on Connections
Now, we’ll write the function to draw tracks according to their connections. Straight and curved tracks will be drawn dynamically, based on which sides are connected.
game.js (continued):
// Function to draw tracks on the grid based on connections function drawTracks() { for (let row = 0; row < gridSize; row++) { for (let col = 0; col < gridSize; col++) { const { connections, hasTrack } = grid[row][col]; if (hasTrack) { ctx.strokeStyle = 'black'; ctx.lineWidth = 4; ctx.beginPath(); // Draw based on connections if (connections.top) { ctx.moveTo(col * cellSize + cellSize / 2, row * cellSize); ctx.lineTo(col * cellSize + cellSize / 2, row * cellSize + cellSize / 2); } if (connections.right) { ctx.moveTo(col * cellSize + cellSize / 2, row * cellSize + cellSize / 2); ctx.lineTo(col * cellSize + cellSize, row * cellSize + cellSize / 2); } if (connections.bottom) { ctx.moveTo(col * cellSize + cellSize / 2, row * cellSize + cellSize / 2); ctx.lineTo(col * cellSize + cellSize / 2, row * cellSize + cellSize); } if (connections.left) { ctx.moveTo(col * cellSize, row * cellSize + cellSize / 2); ctx.lineTo(col * cellSize + cellSize / 2, row * cellSize + cellSize / 2); } ctx.stroke(); } } } }
Step 5: Adding Interaction
To complete our game, we’ll add interaction to handle mouse clicks for adding and removing tracks, and mouse movements for highlighting cells.
// Handle mouse clicks on the canvas canvas.addEventListener('click', (event) => { const rect = canvas.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; const { row, col } = getGridPosition(x, y); // Toggle track placement if (grid[row][col].hasTrack) { // Remove track if it exists grid[row][col].hasTrack = false; grid[row][col].connections = { top: false, right: false, bottom: false, left: false }; // Reset connections drawGrid(); drawTracks(); } else { // Add track addTrack(row, col); } }); // Handle mouse movement over the canvas canvas.addEventListener('mousemove', (event) => { const rect = canvas.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; const { row, col } = getGridPosition(x, y); // Update highlighted cell if it has changed if (highlightedCell.row !== row || highlightedCell.col !== col) { highlightedCell = { row, col }; drawGrid(); // Redraw the grid to update the highlight drawTracks(); // Redraw the tracks on top } });
Step 6: Final Touches and Testing
With all the code in place, test your game to ensure tracks connect and disconnect correctly based on user interactions. Adjust the drawing functions or event handlers as needed to fine-tune the game experience.
Key Concepts Explained
Canvas Drawing: We use the Canvas API to draw the grid and tracks. By clearing and redrawing the canvas on every interaction, we maintain an accurate visual representation of the grid state.
Grid Representation: Each cell in the grid is represented as an object with properties to manage connections to adjacent cells, allowing dynamic updates and drawing based on connections.
Event Handling: We handle mouse clicks to add or remove tracks and use mouse movements to highlight the cell currently under the cursor, providing intuitive feedback to the player.
Representing Connections Using a Graph Structure in a Grid-Based Track Game
In our track-building game, each cell in the grid is represented as a node in a graph-like structure. This approach allows us to dynamically manage connections between track pieces, ensuring they connect correctly based on their relative positions. Understanding how we represent these connections is key to making the game function intuitively and correctly.
What Is a Graph Representation?
A graph consists of nodes (or vertices) and edges connecting those nodes. In our game, each grid cell acts as a node, and edges represent connections between track pieces. This graph structure enables us to model the relationships between adjacent tracks, allowing for automatic connections whenever tracks meet.
Step-by-Step Guide to Graph Representation
Step 1: Define the Node Structure
Each node (or cell) in our grid is represented by an object with the following properties:
- connections: An object that keeps track of connections in four directions: top, right, bottom, and left.
- hasTrack: A boolean indicating whether the cell contains a track piece.
Here's the structure of each node:
// Example node structure { connections: { top: false, right: false, bottom: false, left: false }, hasTrack: false // Indicates if the cell has a track }
Step 2: Initialize the Grid
We initialize the grid as a 2D array of nodes using JavaScript's Array.from() method. Each node starts with no connections and no track.
// Initialize a 2D array to represent the grid as nodes const grid = Array.from({ length: gridSize }, () => Array.from({ length: gridSize }, () => ({ connections: { top: false, right: false, bottom: false, left: false }, hasTrack: false // Indicates if the cell has a track })) );
Step 3: Adding a Track and Updating Connections
When a track is added to a cell, we update its connections based on the presence of adjacent tracks. This process involves checking the nodes (cells) around the current cell and establishing bidirectional connections where appropriate.
// Function to add a track and update connections automatically function addTrack(row, col) { // Check bounds if (row < 0 || row >= gridSize || col < 0 || col >= gridSize) return; const node = grid[row][col]; // Place a track and update the node node.hasTrack = true; // Update connections with adjacent cells updateConnections(row, col); // Redraw the grid and tracks drawGrid(); drawTracks(); }
Update Connections Function:
This function establishes connections between the current node and its neighbors:
Top Connection: If the cell above has a track, connect the current node’s top to the above node’s bottom.
Right Connection: If the cell to the right has a track, connect the current node’s right to the right node’s left.
Bottom Connection: If the cell below has a track, connect the current node’s bottom to the below node’s top.
Left Connection: If the cell to the left has a track, connect the current node’s left to the left node’s right.
// Update connections with adjacent cells function updateConnections(row, col) { const node = grid[row][col]; // Check and connect with top neighbor if (row > 0 && grid[row - 1][col].hasTrack) { node.connections.top = true; grid[row - 1][col].connections.bottom = true; // Bidirectional connection } // Check and connect with right neighbor if (col < gridSize - 1 && grid[row][col + 1].hasTrack) { node.connections.right = true; grid[row][col + 1].connections.left = true; // Bidirectional connection } // Check and connect with bottom neighbor if (row < gridSize - 1 && grid[row + 1][col].hasTrack) { node.connections.bottom = true; grid[row + 1][col].connections.top = true; // Bidirectional connection } // Check and connect with left neighbor if (col > 0 && grid[row][col - 1].hasTrack) { node.connections.left = true; grid[row][col - 1].connections.right = true; // Bidirectional connection } }
Step 4: Drawing Tracks Based on Connections
To visualize the tracks, we draw lines on the canvas corresponding to the connections between nodes. The lines represent tracks, and we draw them according to the established connections.
Drawing Tracks:
Check Connections: For each node with a track, check its connections. Draw Lines: Draw lines between connected sides of adjacent nodes using the canvas lineTo() function.
// Function to draw tracks on the grid based on connections function drawTracks() { for (let row = 0; row < gridSize; row++) { for (let col = 0; col < gridSize; col++) { const { connections, hasTrack } = grid[row][col]; if (hasTrack) { ctx.strokeStyle = 'black'; ctx.lineWidth = 4; ctx.beginPath(); // Draw based on connections if (connections.top) { ctx.moveTo(col * cellSize + cellSize / 2, row * cellSize); ctx.lineTo(col * cellSize + cellSize / 2, row * cellSize + cellSize / 2); } if (connections.right) { ctx.moveTo(col * cellSize + cellSize / 2, row * cellSize + cellSize / 2); ctx.lineTo(col * cellSize + cellSize, row * cellSize + cellSize / 2); } if (connections.bottom) { ctx.moveTo(col * cellSize + cellSize / 2, row * cellSize + cellSize / 2); ctx.lineTo(col * cellSize + cellSize / 2, row * cellSize + cellSize); } if (connections.left) { ctx.moveTo(col * cellSize, row * cellSize + cellSize / 2); ctx.lineTo(col * cellSize + cellSize / 2, row * cellSize + cellSize / 2); } ctx.stroke(); } } } }
Why This Works
Graph-Based Representation: By treating each grid cell as a node and connections as edges, we effectively model the grid as a graph. This structure makes it easy to manage and update connections dynamically.
Bidirectional Connections: The connections are bidirectional, meaning if one node connects to another, the reverse is also true. This ensures that connections are consistent and correctly represented visually.
Automatic Updates: Each time a track is added, the connections update automatically based on adjacent nodes. This eliminates the need for manual adjustments, making the game intuitive and easy to interact with.
Conclusion
Using a graph-based approach to represent tracks and connections in a grid simplifies the logic required to manage track placement and drawing. Each cell's connections are dynamically updated based on adjacent cells, ensuring that tracks automatically connect where appropriate. This approach not only makes the implementation more robust but also provides a flexible foundation for adding more complex features in the future, such as moving trains or additional track types.
By following this guide, you should now have a solid understanding of how to use a graph-like structure to manage grid-based connections in a JavaScript game, leveraging the power of the Canvas API for dynamic visual feedback.# canvas-js-graph-learning-example