H0W_T0: Pixel_Game
Here’s a beginner’s guide to creating a React farming simulation game. This application will feature animated game elements, in-game currency, and a day-night cycle, all rendered on an HTML canvas.
1. Set up a New React Project
Section titled “1. Set up a New React Project”First, you’ll need Node.js
and npm
(or pnpm
/ yarn
) installed on your machine. If you don’t have them, download them from the source links below:
Open your terminal or command prompt and run the following command to create a new React
project using Vite
(a fast build tool often used with React
):
npm create vite@latest pixel-game -- --template react-ts
pixel-game
: This specifies your project name.--template react-ts
: This specifies that you want aReact
project withTypeScript
.
Navigate into your new project directory:
cd pixel-game
Install the necessary dependencies:
npm install
2. Install Tailwind CSS
Section titled “2. Install Tailwind CSS”This project uses Tailwind CSS
for styling. A utility-first CSS
framework for rapidly building custom user interfaces. You can check Tailwind
documentation using link below:
Install it by running:
npm install -D tailwindcss postcss autoprefixernpx tailwindcss init -p
This will create tailwind.config.js
and postcss.config.js
files in your project root.
2.1. Configure Tailwind CSS
Section titled “2.1. Configure Tailwind CSS”Open tailwind.config.js
and configure the content array to scan all your React
components for Tailwind
classes:
/** @type {import('tailwindcss').Config} */export default { content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], theme: { extend: {} }, plugins: []}
Open your main CSS
file (e.g., src/index.css
or src/App.css
) and add the Tailwind
directives:
@tailwind base;@tailwind components;@tailwind utilities;
/* Optional: Basic body styling for full-screen background */body { margin: 0; padding: 0; box-sizing: border-box; background-color: #1a202c; /* Dark background */ font-family: 'Inter', sans-serif; /* Recommended font */}
/* Basic styles for the game canvas container */.canvas-card { background-color: #2d3748; /* A dark slate background */ border-radius: 0.5rem; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); overflow: hidden; /* Ensures content stays within rounded corners */ display: flex; justify-content: center; align-items: center;}
canvas { display: block; /* Remove extra space below canvas */ /* The size of the canvas element itself will be set dynamically via React component props */}
3. Create utility Functions and Classes
Section titled “3. Create utility Functions and Classes”Create a new folder src/utils
and recreate same files structure inside the folder using JavaScript code samples provided below.
3.1. Game data types definitions
Section titled “3.1. Game data types definitions”// Type definitions for game dataexport type PositionTurple = [number, number, number, number, number, number, number, number];
export interface SlotUpgrade { price: number; profitMultiplier: number;}
export interface LevelSlot { position: PositionTurple; animationPosition: PositionTurple; uiPosition: PositionTurple; clickPosition: [number, number]; // [x_grid_unit, y_grid_unit] price: number; profitValue: number; upgradesMultiplier: number;}
export interface Level { slots: Record<string, LevelSlot>; slotsUpgrades: SlotUpgrade[]; slotStates: { count: number; updateCallback: (dayCycle: number) => number; }; assets: { url: string; // Base URL for assets suffix: string; // e.g., '.png' statesCount: number; statesPrefix: string; // e.g., '/Farm_' framesCount: number; framesPrefix: string; // e.g., '/Farm_Animation_' uiCount: number; uiPrefix: string; // e.g., '/Farm_UI_' }; ui: { position: { upgrades: PositionTurple; money: PositionTurple; time: PositionTurple; }; upgradesPages: Array<{ slots: [number, number]; // [start_index, end_index] of slots for this page position: PositionTurple; }>; };}
Understanding the Code
Section titled “Understanding the Code”Let’s break down the key parts of the src/utils/types.ts
file:
PositionTurple
: Defines an array for image drawing parameters (source X, Y, width, height, destination X, Y, width, height).SlotUpgrade
,LevelSlot
,Level
: Interfaces that precisely structure the game’s data, including prices, profits, positions, and asset configurations for different game elements (slots) and the overall level.
3.2. Game levels configuration object
Section titled “3.2. Game levels configuration object”import { type Level } from './levels'
// Game level configuration (farm level data)const farm: Level = { slots: { grain: { position: [4, 4, 5, 4, 4, 4, 5, 4], animationPosition: [2, 4, 3, 2, 2, 4, 3, 2], uiPosition: [0, 0.5, 3, 2, 0, 0.5, 3, 2], clickPosition: [1, 1], price: 5, profitValue: 2, upgradesMultiplier: 1 }, corn: { position: [12, 3, 5, 5, 12, 3, 5, 5], animationPosition: [16, 4, 3, 2, 16, 4, 3, 2], uiPosition: [0, 2.5, 3, 2, 0, 2.5, 3, 2], clickPosition: [1, 3], price: 10, profitValue: 3, upgradesMultiplier: 2 }, cauliflower: { position: [20, 4, 5, 4, 20, 4, 5, 4], animationPosition: [21, 6, 1, 2, 21, 6, 1, 2], uiPosition: [0, 4.5, 3, 2, 0, 4.5, 3, 2], clickPosition: [1, 5], price: 25, profitValue: 4, upgradesMultiplier: 5 }, cabbage: { position: [28, 4, 5, 4, 28, 4, 5, 4], animationPosition: [26, 3, 3, 2, 26, 3, 3, 2], uiPosition: [0, 6.5, 3, 2, 0, 6.5, 3, 2], clickPosition: [1, 7], price: 45, profitValue: 5, upgradesMultiplier: 9 }, onion: { position: [36, 4, 5, 4, 36, 4, 5, 4], animationPosition: [40, 3, 3, 2, 40, 3, 3, 2], uiPosition: [0, 8.5, 3, 2, 0, 8.5, 3, 2], clickPosition: [1, 9], price: 70, profitValue: 6, upgradesMultiplier: 14 }, carrot: { position: [4, 9, 5, 4, 4, 9, 5, 4], animationPosition: [7, 11, 1, 2, 7, 11, 1, 2], uiPosition: [0, 10.5, 3, 2, 0, 10.5, 3, 2], clickPosition: [1, 11], price: 100, profitValue: 7, upgradesMultiplier: 20 }, tomato: { position: [12, 8, 5, 5, 12, 8, 5, 5], animationPosition: [14, 11, 1, 2, 14, 11, 1, 2], uiPosition: [0, 12.5, 3, 2, 0, 12.5, 3, 2], clickPosition: [1, 13], price: 135, profitValue: 8, upgradesMultiplier: 27 }, radish: { position: [20, 9, 5, 4, 20, 9, 5, 4], animationPosition: [18, 8, 3, 2, 18, 8, 3, 2], uiPosition: [0, 14.5, 3, 2, 0, 14.5, 3, 2], clickPosition: [1, 15], price: 175, profitValue: 9, upgradesMultiplier: 35 }, turnip: { position: [28, 9, 5, 4, 28, 9, 5, 4], animationPosition: [32, 9, 3, 2, 32, 9, 3, 2], uiPosition: [0, 16.5, 3, 2, 0, 16.5, 3, 2], clickPosition: [1, 17], price: 220, profitValue: 10, upgradesMultiplier: 44 }, chiliPepper: { position: [36, 9, 5, 4, 36, 9, 5, 4], animationPosition: [39, 11, 1, 2, 39, 11, 1, 2], uiPosition: [0, 18.5, 3, 2, 0, 18.5, 3, 2], clickPosition: [1, 19], price: 270, profitValue: 11, upgradesMultiplier: 54 }, zucchini: { position: [4, 13, 5, 5, 4, 13, 5, 5], animationPosition: [8, 13, 3, 2, 8, 13, 3, 2], uiPosition: [0, 20.5, 3, 2, 0, 20.5, 3, 2], clickPosition: [1, 21], price: 325, profitValue: 12, upgradesMultiplier: 65 }, pumpkin: { position: [12, 13, 5, 5, 12, 13, 5, 5], animationPosition: [16, 16, 1, 2, 16, 16, 1, 2], uiPosition: [0, 22.5, 3, 2, 0, 22.5, 3, 2], clickPosition: [1, 23], price: 385, profitValue: 13, upgradesMultiplier: 77 }, strawberry: { position: [20, 14, 5, 4, 20, 14, 5, 4], animationPosition: [23, 16, 1, 2, 23, 16, 1, 2], uiPosition: [0, 24.5, 3, 2, 0, 24.5, 3, 2], clickPosition: [1, 25], price: 450, profitValue: 14, upgradesMultiplier: 90 }, grapes: { position: [28, 13, 5, 5, 28, 13, 5, 5], animationPosition: [32, 16, 1, 2, 32, 16, 1, 2], uiPosition: [0, 26.5, 3, 2, 0, 26.5, 3, 2], clickPosition: [1, 27], price: 520, profitValue: 15, upgradesMultiplier: 104 }, berry: { position: [36, 13, 5, 5, 36, 13, 5, 5], animationPosition: [34, 14, 3, 2, 34, 14, 3, 2], uiPosition: [0, 28.5, 3, 2, 0, 28.5, 3, 2], clickPosition: [1, 29], price: 595, profitValue: 16, upgradesMultiplier: 119 }, watermelon: { position: [4, 18, 5, 5, 4, 18, 5, 5], animationPosition: [5, 21, 1, 2, 5, 21, 1, 2], uiPosition: [0, 30.5, 3, 2, 0, 30.5, 3, 2], clickPosition: [1, 31], price: 675, profitValue: 17, upgradesMultiplier: 135 }, pineapple: { position: [12, 18, 5, 5, 12, 18, 5, 5], animationPosition: [10, 18, 3, 2, 10, 18, 3, 2], uiPosition: [0, 32.5, 3, 2, 0, 32.5, 3, 2], clickPosition: [1, 33], price: 760, profitValue: 18, upgradesMultiplier: 152 }, pricklyPear: { position: [20, 18, 5, 5, 20, 18, 5, 5], animationPosition: [24, 19, 3, 2, 24, 19, 3, 2], uiPosition: [0, 34.5, 3, 2, 0, 34.5, 3, 2], clickPosition: [1, 35], price: 850, profitValue: 19, upgradesMultiplier: 170 }, cotton: { position: [28, 19, 5, 4, 28, 19, 5, 4], animationPosition: [30, 21, 1, 2, 30, 21, 1, 2], uiPosition: [0, 36.5, 3, 2, 0, 36.5, 3, 2], clickPosition: [1, 37], price: 945, profitValue: 20, upgradesMultiplier: 189 }, appleTree: { position: [3, 24, 16.5, 9, 3, 24, 16.5, 9], animationPosition: [7, 30, 8, 3, 7, 30, 8, 3], uiPosition: [0, 0.5, 3, 2, 0, 0.5, 3, 2], clickPosition: [1, 1], price: 1045, profitValue: 22, upgradesMultiplier: 209 }, orangeTree: { position: [19.5, 24, 16.5, 9, 19.5, 24, 16.5, 9], animationPosition: [23, 30, 4, 3, 23, 30, 4, 3], uiPosition: [0, 2.5, 3, 2, 0, 2.5, 3, 2], clickPosition: [1, 3], price: 1155, profitValue: 25, upgradesMultiplier: 231 }, apricotTree: { position: [3, 34, 16, 8, 3, 34, 16, 8], animationPosition: [9, 40, 9, 2, 9, 40, 9, 2], uiPosition: [0, 4.5, 3, 2, 0, 4.5, 3, 2], clickPosition: [1, 5], price: 1280, profitValue: 28, upgradesMultiplier: 256 }, lemonTree: { position: [20, 33, 14, 9, 20, 33, 14, 9], animationPosition: [23, 40, 7, 2, 23, 40, 7, 2], uiPosition: [0, 6.5, 3, 2, 0, 6.5, 3, 2], clickPosition: [1, 7], price: 1420, profitValue: 31, upgradesMultiplier: 284 }, mapleTree: { position: [49, 39, 7, 3, 49, 39, 7, 3], animationPosition: [49, 39, 7, 3, 49, 39, 7, 3], uiPosition: [0, 8.5, 3, 2, 0, 8.5, 3, 2], clickPosition: [1, 9], price: 1575, profitValue: 34, upgradesMultiplier: 315 }, pineTree: { position: [63, 40, 2, 2, 63, 40, 2, 2], animationPosition: [63, 40, 2, 2, 63, 40, 2, 2], uiPosition: [0, 10.5, 3, 2, 0, 10.5, 3, 2], clickPosition: [1, 11], price: 1745, profitValue: 37, upgradesMultiplier: 349 }, oakTree: { position: [73, 41, 2, 2, 73, 41, 2, 2], animationPosition: [73, 41, 2, 2, 73, 41, 2, 2], uiPosition: [0, 12.5, 3, 2, 0, 12.5, 3, 2], clickPosition: [1, 13], price: 1930, profitValue: 40, upgradesMultiplier: 386 }, chicken: { position: [45, 4, 6, 5, 45, 4, 6, 5], animationPosition: [45, 4, 6, 5, 45, 4, 6, 5], uiPosition: [0, 0.5, 3, 2, 0, 0.5, 3, 2], clickPosition: [1, 1], price: 2130, profitValue: 42, upgradesMultiplier: 426 }, sheep: { position: [54, 3, 6, 6, 54, 3, 6, 6], animationPosition: [54, 3, 6, 6, 54, 3, 6, 6], uiPosition: [0, 2.5, 3, 2, 0, 2.5, 3, 2], clickPosition: [1, 3], price: 2340, profitValue: 44, upgradesMultiplier: 468 }, cow: { position: [62, 2, 8, 7, 62, 2, 8, 7], animationPosition: [62, 2, 8, 7, 62, 2, 8, 7], uiPosition: [0, 4.5, 3, 2, 0, 4.5, 3, 2], clickPosition: [1, 5], price: 2560, profitValue: 46, upgradesMultiplier: 512 }, pig: { position: [72, 3, 6, 6, 72, 3, 6, 6], animationPosition: [72, 3, 6, 6, 72, 3, 6, 6], uiPosition: [0, 6.5, 3, 2, 0, 6.5, 3, 2], clickPosition: [1, 7], price: 2790, profitValue: 48, upgradesMultiplier: 558 }, duck: { position: [45, 10, 7, 6, 45, 10, 7, 6], animationPosition: [45, 10, 7, 6, 45, 10, 7, 6], uiPosition: [0, 8.5, 3, 2, 0, 8.5, 3, 2], clickPosition: [1, 9], price: 3030, profitValue: 50, upgradesMultiplier: 606 }, furGoat: { position: [54, 9, 6, 7, 54, 9, 6, 7], animationPosition: [54, 9, 6, 7, 54, 9, 6, 7], uiPosition: [0, 10.5, 3, 2, 0, 10.5, 3, 2], clickPosition: [1, 11], price: 3280, profitValue: 52, upgradesMultiplier: 656 }, goat: { position: [63, 10, 6, 6, 63, 10, 6, 6], animationPosition: [63, 10, 6, 6, 63, 10, 6, 6], uiPosition: [0, 12.5, 3, 2, 0, 12.5, 3, 2], clickPosition: [1, 13], price: 3540, profitValue: 54, upgradesMultiplier: 708 }, donkey: { position: [71, 10, 7, 6, 71, 10, 7, 6], animationPosition: [71, 10, 7, 6, 71, 10, 7, 6], uiPosition: [0, 14.5, 3, 2, 0, 14.5, 3, 2], clickPosition: [1, 15], price: 3810, profitValue: 56, upgradesMultiplier: 762 }, fish: { position: [37, 28, 5, 9, 37, 28, 5, 9], animationPosition: [37, 28, 5, 9, 37, 28, 5, 9], uiPosition: [0, 16.5, 3, 2, 0, 16.5, 3, 2], clickPosition: [1, 17], price: 4090, profitValue: 58, upgradesMultiplier: 818 }, dog1: { position: [53, 17, 7, 4, 53, 17, 7, 4], animationPosition: [53, 17, 7, 4, 53, 17, 7, 4], uiPosition: [0, 18.5, 3, 2, 0, 18.5, 3, 2], clickPosition: [1, 19], price: 4380, profitValue: 60, upgradesMultiplier: 876 }, dog2: { position: [62, 17, 7, 4, 62, 17, 7, 4], animationPosition: [62, 17, 7, 4, 62, 17, 7, 4], uiPosition: [0, 20.5, 3, 2, 0, 20.5, 3, 2], clickPosition: [1, 21], price: 4680, profitValue: 65, upgradesMultiplier: 936 }, dog3: { position: [73, 16, 5, 5, 73, 16, 5, 5], animationPosition: [73, 16, 5, 5, 73, 16, 5, 5], uiPosition: [0, 22.5, 3, 2, 0, 22.5, 3, 2], clickPosition: [1, 23], price: 5005, profitValue: 70, upgradesMultiplier: 1001 }, wateringCan: { position: [71, 27, 1, 2, 71, 27, 1, 2], animationPosition: [71, 27, 1, 2, 71, 27, 1, 2], uiPosition: [0, 0.5, 3, 2, 0, 0.5, 3, 2], clickPosition: [1, 1], price: 7500, profitValue: 104, upgradesMultiplier: 1500 }, axe: { position: [71, 27, 1, 2, 71, 27, 1, 2], animationPosition: [71, 27, 1, 2, 71, 27, 1, 2], uiPosition: [0, 2.5, 3, 2, 0, 2.5, 3, 2], clickPosition: [1, 3], price: 8225, profitValue: 145, upgradesMultiplier: 1645 }, scissors: { position: [71, 27, 1, 2, 71, 27, 1, 2], animationPosition: [71, 27, 1, 2, 71, 27, 1, 2], uiPosition: [0, 4.5, 3, 2, 0, 4.5, 3, 2], clickPosition: [1, 5], price: 9240, profitValue: 186, upgradesMultiplier: 1848 }, fishingRod: { position: [71, 27, 1, 2, 71, 27, 1, 2], animationPosition: [71, 27, 1, 2, 71, 27, 1, 2], uiPosition: [0, 6.5, 3, 2, 0, 6.5, 3, 2], clickPosition: [1, 7], price: 10545, profitValue: 227, upgradesMultiplier: 2109 }, shovel: { position: [71, 27, 1, 2, 71, 27, 1, 2], animationPosition: [71, 27, 1, 2, 71, 27, 1, 2], uiPosition: [0, 8.5, 3, 2, 0, 8.5, 3, 2], clickPosition: [1, 9], price: 12130, profitValue: 267, upgradesMultiplier: 2426 } }, slotsUpgrades: [ { price: 5, profitMultiplier: 2 }, { price: 10, profitMultiplier: 3 }, { price: 15, profitMultiplier: 4 }, { price: 20, profitMultiplier: 5 } ], slotStates: { count: 5, updateCallback: (dayCycle: number) => (dayCycle > 0 ? Math.floor((dayCycle - 1) / 3) + 1 : 1) }, // Using placeholder URLs for demonstration. Replace with actual asset paths. assets: { url: 'https://placehold.co/', // Base URL for placeholder images suffix: '.png', statesCount: 5, statesPrefix: '48x48/CCCCCC/000000?text=S', // S1, S2, S3, S4, S5 framesCount: 32, framesPrefix: '48x48/00FF00/000000?text=A', // A1, A2, ... A32 uiCount: 5, uiPrefix: '48x48/0000FF/FFFFFF?text=UI', // UI1, UI2, ... UI5 }, ui: { position: { upgrades: [0, 0, 6, 40, 0, 0, 6, 40], money: [0, 0, 11, 3, 69, 0, 11, 3], time: [0, 3, 6, 1, 63, 1, 6, 1] }, upgradesPages: [ { slots: [0, 18], position: [4, 1, 1, 1, 4, 1, 1, 1] }, // Crop slots { slots: [19, 25], position: [4, 3, 1, 1, 4, 3, 1, 1] }, // Tree slots { slots: [26, 37], position: [4, 5, 1, 1, 4, 5, 1, 1] }, // Animal slots { slots: [38, 42], position: [4, 7, 1, 1, 4, 7, 1, 1] } // Tool slots ] }};
// Map of levelsexport const levels: Record<string, Level> = { farm };
Understanding the Code
Section titled “Understanding the Code”Let’s break down the key parts of the src/utils/levels.ts
file:
farm
object defines all the static data for the “farm” level:slots
: Details for each purchasable item (grain, corn, chicken, appleTree, etc.), including their positions on the screen (position
), animation sprite coordinates (animationPosition
), UI icon positions (uiPosition
), and game mechanics likeprice
,profitValue
, andupgradesMultiplier
.slotsUpgrades
: An array defining the price and profit multiplier for each upgrade level.slotStates
: Configuration for how many growth states a slot has and a callback to determine the current state based on the day cycle.assets
: Defines the base URL, suffix, and prefixes for loading all the game’s image assets (states, frames, UI, and font).ui
: Positions for general UI elements like upgrades menu, money display, and time display, as well as configurations for different pages within the upgrades menu.
3.3. Font configuration object
Section titled “3.3. Font configuration object”// Font sprite sheet coordinatesexport const font = { en: { a: [0, 0, 1, 1] // Example, actual char positions would be here }, // Returns PositionTurple for a digit from the font sprite sheet digits: (digit: number, position: [number, number, number, number]): PositionTurple => { // [sprite_x, sprite_y, sprite_width, sprite_height, dest_x, dest_y, dest_width, dest_height] return [digit, 17, 1, 1, position[0], position[1], position[2], position[3]] as PositionTurple; }};
Understanding the Code
Section titled “Understanding the Code”Let’s break down the key parts of the src/utils/font.ts
file:
- Contains data for drawing text (specifically digits for money display) from a font sprite sheet.
font.digits
calculates the correct source and destination coordinates for drawing individual numbers.
3.4. utils.ts
main export file
Section titled “3.4. utils.ts main export file”import { levels } from './levels'import { font } from './font'
export { levels, font }
Understanding the Code
Section titled “Understanding the Code”Let’s break down the key parts of the src/utils/utils.ts
file:
- The primary purpose of this file is to act as a central hub for all utility functions for our project. It imports functions from other files within the same
/utils
directory and then exports them all from this single file.
4. Add the Game
React Component
Section titled “4. Add the Game React Component”Create a new file src/Game.tsx
and paste the entire React code provided below into it.
import React, { useState, useEffect, useCallback, useRef } from 'react';import { levels, font } from './utils/utils'import { type PositionTurple, type Level } from './utils/types'
// Helper function to get level configfunction generateLevel(key: string): Level { return levels[key];}
// Global window properties (for animation loop IDs and game state for internal use by drawing logic)// These are not ideal in React, but mirror the Solid.js global approach.// In a real app, these setInterval IDs would be managed purely within refs/state.declare global { interface Window { animationFrame: number; animationLoop: number | NodeJS.Timeout | null; purchaseAnimation: number | null; dayLoop: number | NodeJS.Timeout | null; gameState: { levels?: { [key: string]: { slotsUpgrades: Record<string, number>; slotsActive: Record<string, boolean>; }; }; dayCycle?: number; playerMoney?: number; }; levelConfig?: Level; // Store current level config globally for drawing functions }}
const Game: React.FC = () => { // Determine if the device is mobile based on screen width const [isMobile, setIsMobile] = useState(window.innerWidth < 768); // Reference to the canvas element const canvasRef = useRef<HTMLCanvasElement>(null);
// State for the current level (fixed as 'farm' for this app) const [level] = useState('farm'); // State to track if all level assets are loaded const [levelLoaded, setLevelLoaded] = useState(false); // State to store loaded Image objects for game assets const [levelImages, setLevelImages] = useState<{ states?: HTMLImageElement[]; frames?: HTMLImageElement[]; ui?: HTMLImageElement[]; font?: HTMLImageElement; }>({});
// State for active slots (plants/animals purchased) - loaded from localStorage const [activeSlots, setActiveSlots] = useState<Record<string, boolean>>(() => { const savedState = JSON.parse(localStorage.getItem('gameState') ?? '{}'); return savedState?.levels?.[level]?.slotsActive ?? {}; }); // State for slot upgrades - loaded from localStorage const [slotsUpgrades, setSlotsUpgrades] = useState<Record<string, number>>(() => { const savedState = JSON.parse(localStorage.getItem('gameState') ?? '{}'); return savedState?.levels?.[level]?.slotsUpgrades ?? {}; }); // State for current day cycle - loaded from localStorage const [dayCycle, setDayCycle] = useState<number>(() => { const savedState = JSON.parse(localStorage.getItem('gameState') ?? '{}'); return savedState?.dayCycle ?? 0; }); // State for player money - loaded from localStorage const [playerMoney, setPlayerMoney] = useState<number>(() => { const savedState = JSON.parse(localStorage.getItem('gameState') ?? '{}'); return savedState?.playerMoney ?? 5; // Start with 5 money if no saved state });
// State to control purchase animation feedback const [purchaseAnimation, setPurchaseAnimation] = useState<string | false>(false);
// Refs for animation/interval IDs to manage them across re-renders const animationFrameRef = useRef(0); // Current frame of the animation const animationLoopId = useRef<NodeJS.Timeout | null>(null); // ID for the main animation loop const purchaseAnimationFrameRef = useRef(0); // Frame for specific purchase animation const dayLoopId = useRef<NodeJS.Timeout | null>(null); // ID for the day cycle loop
// State for current upgrades UI page const [upgradesPage, setUpgradesPage] = useState<number>(0);
// Calculate dynamic canvas dimensions based on screen size and aspect ratio const innerWidth = isMobile ? 360 : 720; const innerHeight = isMobile ? 203 : 405; const aspectRatio = innerWidth / innerHeight; // Base unit (48px in original assets) scaled by aspect ratio const aspectWidth = aspectRatio < 1.7777 ? innerWidth / 3840 : (innerHeight * 1.7777) / 3840; const aspectHeight = aspectRatio > 1.7777 ? innerHeight / 2160 : (innerWidth / 1.7777) / 2160;
// Styles for the container div to ensure correct scaling const styleWidth = `${aspectRatio < 1.7777 ? innerWidth : innerHeight * 1.7777}px`; const styleHeight = `${aspectRatio > 1.7777 ? innerHeight : innerWidth / 1.7777}px`;
/** * Helper function to convert grid positions to pixel styles for absolute positioning. * @param posArray Array of grid positions [x, y, width, height] * @returns CSS style object for positioning */ const styleFromPosition = useCallback((posArray: number[]) => { // Assuming 1 grid unit = 48 pixels in the original asset design const [gridX, gridY, gridWidth, gridHeight] = posArray; return { position: 'absolute' as const, left: `${gridX * 48 * aspectWidth}px`, top: `${gridY * 48 * aspectHeight}px`, width: `${gridWidth * 48 * aspectWidth}px`, height: `${gridHeight * 48 * aspectHeight}px`, }; }, [aspectWidth, aspectHeight]); // Recalculate if aspect ratios change
/** * Draws UI elements onto the canvas. * @param ctx The canvas rendering context. * @param uiPosition Object containing position data for UI elements. */ const updateUi = useCallback((ctx: CanvasRenderingContext2D, uiPosition: typeof levels.farm.ui.position) => { if (!levelImages.ui || !levelImages.font) return; // Ensure images are loaded
// Draw upgrades UI page background ctx.drawImage( levelImages.ui[upgradesPage], ...(uiPosition.upgrades.map((pos, index) => pos * 48 + (index === 0 || index === 1 ? 6 : 0)) as PositionTurple) );
// Draw money UI element ctx.drawImage( levelImages.ui[4], // Assuming UI asset index 4 is the money icon ...(uiPosition.money.map((pos) => pos * 48) as PositionTurple) );
// Draw time (day cycle) UI element // The original code uses .toSpliced() which is an immutable way to modify an array. // Recreating the array for mapping: const timePosAdjusted = [...uiPosition.time]; timePosAdjusted[0] = (dayCycle * 6); // Adjust X position for animation/day cycle ctx.drawImage( levelImages.ui[4], // Assuming UI asset index 4 is also used for time background ...(timePosAdjusted.map((pos) => pos * 48) as PositionTurple) );
// Draw player money digits const moneyString = playerMoney.toString().padStart(8, '0'); for (const [index, digitChar] of moneyString.split('').entries()) { const digit = Number(digitChar); // Calculate position for each digit on the UI const digitPos: [number, number, number, number] = [71 + index, 1, 1, 1]; // Grid units ctx.drawImage( levelImages.font, ...(font.digits(digit, digitPos).map((pos) => pos * 48) as PositionTurple) ); }
}, [levelImages, upgradesPage, dayCycle, playerMoney]); // Dependencies for updateUi
// Effect for loading all game assets useEffect(() => { const currentLevelConfig = generateLevel(level); window.levelConfig = currentLevelConfig; // Store globally for canvas drawing functions
const loadSingleImage = (src: string): Promise<HTMLImageElement> => { return new Promise((resolve, reject) => { const img = new Image(); img.src = src; img.crossOrigin = 'anonymous'; // Important for CORS if images are from a different origin img.onload = () => resolve(img); img.onerror = (e) => { console.error(`Failed to load image: ${src}`, e); // Provide a fallback placeholder if image loading fails for a production app const errorImg = new Image(); errorImg.src = `https://placehold.co/48x48/FF0000/FFFFFF?text=ERR`; // Generic error placeholder errorImg.onload = () => resolve(errorImg); errorImg.onerror = () => reject(new Error(`Fallback failed for ${src}`)); }; }); };
const loadAssets = async () => { try { const statesPromises = Array.from(new Array(currentLevelConfig.assets.statesCount).keys()).map( i => loadSingleImage(`${currentLevelConfig.assets.url}${currentLevelConfig.assets.statesPrefix}${i + 1}${currentLevelConfig.assets.suffix}`) ); const framesPromises = Array.from(new Array(currentLevelConfig.assets.framesCount).keys()).map( i => loadSingleImage(`${currentLevelConfig.assets.url}${currentLevelConfig.assets.framesPrefix}${i + 1}${currentLevelConfig.assets.suffix}`) ); const uiPromises = Array.from(new Array(currentLevelConfig.assets.uiCount).keys()).map( i => loadSingleImage(`${currentLevelConfig.assets.url}${currentLevelConfig.assets.uiPrefix}${i + 1}${currentLevelConfig.assets.suffix}`) ); const fontPromise = loadSingleImage(`${currentLevelConfig.assets.url}48x48/FF0000/FFFFFF?text=Font`); // Placeholder for font image
const [states, frames, ui, fontImage] = await Promise.all([ Promise.all(statesPromises), Promise.all(framesPromises), Promise.all(uiPromises), fontPromise ]);
// Sort images by their number in the filename for correct ordering states.sort((a, b) => { const numA = Number(a.src.split(currentLevelConfig.assets.statesPrefix)[1].split(currentLevelConfig.assets.suffix)[0]); const numB = Number(b.src.split(currentLevelConfig.assets.statesPrefix)[1].split(currentLevelConfig.assets.suffix)[0]); return numA - numB; }); frames.sort((a, b) => { const numA = Number(a.src.split(currentLevelConfig.assets.framesPrefix)[1].split(currentLevelConfig.assets.suffix)[0]); const numB = Number(b.src.split(currentLevelConfig.assets.framesPrefix)[1].split(currentLevelConfig.assets.suffix)[0]); return numA - numB; }); ui.sort((a, b) => { const numA = Number(a.src.split(currentLevelConfig.assets.uiPrefix)[1].split(currentLevelConfig.assets.suffix)[0]); const numB = Number(b.src.split(currentLevelConfig.assets.uiPrefix)[1].split(currentLevelConfig.assets.suffix)[0]); return numA - numB; });
setLevelImages({ states, frames, ui, font: fontImage }); animationFrameRef.current = 0; // Initialize animation frame purchaseAnimationFrameRef.current = 0; // Initialize purchase animation frame setLevelLoaded(true); } catch (error) { console.error("Error loading level assets:", error); setLevelLoaded(false); // Indicate failure to load assets } };
loadAssets();
}, [level]); // Re-run when the 'level' state changes
// Effect for the main game animation loop useEffect(() => { if (!levelLoaded || !canvasRef.current || !levelImages.states || !levelImages.frames || !levelImages.ui) { return; // Only run if assets are loaded and canvas is ready }
const canvas = canvasRef.current; const ctx = canvas.getContext('2d'); if (!ctx) return;
const levelSlots = window.levelConfig!.slots; // Access global config const levelSlotsUpgrades = window.levelConfig!.slotsUpgrades; const upgradesPages = window.levelConfig!.ui.upgradesPages;
// Clear any existing interval to prevent multiple loops if (animationLoopId.current !== null) { clearInterval(animationLoopId.current); }
animationLoopId.current = setInterval(() => { // Clear canvas ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw base state (e.g., ground) ctx.drawImage(levelImages.states![0], 0, 0, canvas.width, canvas.height); // Scale to canvas size
// Draw active slots (plants/animals) for (const slotName of Object.keys(activeSlots)) { const slotData = levelSlots[slotName]; if (!slotData) continue;
// Draw the state of the slot based on day cycle const stateIndex = window.levelConfig!.slotStates.updateCallback(dayCycle); ctx.drawImage( levelImages.states![stateIndex], ...(slotData.position.map((pos) => pos * 48) as PositionTurple) );
// Draw animation frame for the slot ctx.drawImage( levelImages.frames![animationFrameRef.current], ...(slotData.animationPosition.map((pos) => pos * 48) as PositionTurple) ); }
// Update and draw UI elements updateUi(ctx, window.levelConfig!.ui.position);
// Logic for drawing slot purchase/upgrade states in UI const currentUpgradesPageSlots = upgradesPages[upgradesPage].slots; const allSlotsKeys = Object.keys(levelSlots);
for (let i = currentUpgradesPageSlots[0]; i <= currentUpgradesPageSlots[1]; i++) { const slotName = allSlotsKeys[i]; const slotData = levelSlots[slotName]; if (!slotData) continue;
// Determine position for UI overlay const uiPos = slotData.uiPosition.map((pos, idx) => pos * 48 + (idx === 0 || idx === 1 ? 6 : 0));
// Handle purchase animation if (purchaseAnimation === slotName) { if (purchaseAnimationFrameRef.current === 4) { // Animation complete setPurchaseAnimation(false); purchaseAnimationFrameRef.current = 0; // Reset } else { // Draw animation frame for purchase // Original Solid.js: .toSpliced(0, 1, (window.purchaseAnimation + 11) * 6) const animUiPos: PositionTurple = [...uiPos]; animUiPos[0] = (purchaseAnimationFrameRef.current + 11) * 48; // Assume anim frame affects X (0th index)
ctx.drawImage( levelImages.ui![upgradesPage], // Use current UI page image ...animUiPos as PositionTurple // Apply the adjusted position ); purchaseAnimationFrameRef.current += 1; } } else { // Draw regular UI state (purchased/can upgrade/can purchase) let uiIndexOffset = 0; // Default to not affordable/not active if (activeSlots[slotName]) { // If slot is active (purchased) const currentUpgradeLevel = slotsUpgrades[slotName] ?? 0; const nextUpgradePrice = (levelSlotsUpgrades[currentUpgradeLevel]?.price ?? Infinity) * slotData.upgradesMultiplier;
if (playerMoney >= nextUpgradePrice) { uiIndexOffset = 3; // Can upgrade } else { uiIndexOffset = 2; // Purchased, but cannot upgrade } } else { // If slot is not active (not purchased) if (playerMoney >= slotData.price) { uiIndexOffset = 1; // Can purchase } } // Original Solid.js: ((slotsUpgrades()[slot] ?? 0) * 2 + X) * 6 // This means: (current_upgrade_level * 2 + (2 or 3 or 1)) * 6 // 6 seems to be the base multiplier for UI sprite X coordinate // Let's re-evaluate based on the original logic let sourceXOffset = 0; if (activeSlots[slotName]) { const currentUpgradeLevel = slotsUpgrades[slotName] ?? 0; const nextUpgradePrice = (levelSlotsUpgrades[currentUpgradeLevel]?.price ?? Infinity) * slotData.upgradesMultiplier; if (playerMoney >= nextUpgradePrice) { sourceXOffset = (currentUpgradeLevel * 2 + 3) * 6 * 48; // Can upgrade } else { sourceXOffset = (currentUpgradeLevel * 2 + 2) * 6 * 48; // Cannot upgrade } } else { if (playerMoney >= slotData.price) { sourceXOffset = 6 * 48; // Can purchase } else { sourceXOffset = 0; // Cannot purchase } }
// Apply this calculated sourceXOffset to the UI sprite. // The original drawing logic: ctx.drawImage(levelImages().ui![upgradesPage()], ...(levelSlots[slot].uiPosition!.toSpliced(0, 1, calculated_x_pos).map(...))) // This implies the uiPosition first value (index 0) is the x-coordinate * 48 of the source image. // The source image's actual x coordinate would be sourceXOffset (if original uiPosition[0] was 0) // Simplified: Assuming uiPosition[0] is always 0 and 6 * 48 is the actual unit step on the sprite sheet. const uiSpriteSourceX = Math.floor(sourceXOffset / 48); // Convert back to grid unit for original uiPosition logic const finalUiPos: PositionTurple = [...slotData.uiPosition]; finalUiPos[0] = uiSpriteSourceX; // Update the source X for drawing
ctx.drawImage( levelImages.ui![upgradesPage], // Current upgrades page UI image // Source X, Source Y, Source Width, Source Height (from slotData.uiPosition) finalUiPos[0] * 48, finalUiPos[1] * 48, finalUiPos[2] * 48, finalUiPos[3] * 48, // Destination X, Destination Y, Destination Width, Destination Height (scaled by aspect ratios) slotData.uiPosition[4] * 48 * aspectWidth, slotData.uiPosition[5] * 48 * aspectHeight, slotData.uiPosition[6] * 48 * aspectWidth, slotData.uiPosition[7] * 48 * aspectHeight ); } }
// Advance animation frame animationFrameRef.current = (animationFrameRef.current + 1) % levelImages.frames.length; }, 100); // 100ms interval for animation frames (10 FPS)
// Cleanup: Clear the interval when component unmounts or dependencies change return () => { if (animationLoopId.current !== null) { clearInterval(animationLoopId.current); animationLoopId.current = null; } }; }, [levelLoaded, levelImages, activeSlots, dayCycle, upgradesPage, purchaseAnimation, playerMoney, updateUi, aspectWidth, aspectHeight]);
// Effect for the day cycle loop useEffect(() => { if (!levelLoaded) { return; // Only run if assets are loaded }
// Clear any existing interval if (dayLoopId.current !== null) { clearInterval(dayLoopId.current); }
dayLoopId.current = setInterval(() => { setDayCycle((prevDay) => { const nextDay = prevDay === 12 ? 0 : prevDay + 1; if (nextDay === 0) { // If it's a new day (after day 12) let totalProfit = 0; const currentLevelConfig = window.levelConfig!; for (const slotName of Object.keys(activeSlots)) { const slotData = currentLevelConfig.slots[slotName]; const upgradeLevel = slotsUpgrades[slotName] ?? 0; const profitMultiplier = currentLevelConfig.slotsUpgrades[upgradeLevel - 1]?.profitMultiplier ?? 1; totalProfit += profitMultiplier * slotData.profitValue; } setPlayerMoney((prevMoney) => prevMoney + Math.ceil(totalProfit)); } return nextDay; }); }, 500); // 500ms per day cycle step
// Cleanup: Clear the interval when component unmounts or dependencies change return () => { if (dayLoopId.current !== null) { clearInterval(dayLoopId.current); dayLoopId.current = null; } }; }, [levelLoaded, activeSlots, slotsUpgrades]); // Dependencies for day cycle loop
// Effect for saving game state to localStorage useEffect(() => { if (levelLoaded) { const currentGameState = { levels: { [level]: { slotsActive: activeSlots, slotsUpgrades: slotsUpgrades } }, dayCycle: dayCycle, playerMoney: playerMoney }; localStorage.setItem('gameState', JSON.stringify(currentGameState)); } }, [levelLoaded, level, activeSlots, slotsUpgrades, dayCycle, playerMoney]); // Save whenever relevant state changes
return ( <div className="relative overflow-hidden flex flex-col items-center justify-center min-h-screen bg-gray-900 text-white p-4"> <div id="game-canvas-div" className={ isMobile ? `canvas-card relative h-[203px] w-[360px]` : `canvas-card relative h-[405px] w-[720px]` } > <div className={isMobile ? `h-[203px] w-[360px]` : `h-[405px] w-[720px]`} style={{ zIndex: 2, position: 'absolute', top: 0, left: 0, display: 'flex', justifyContent: 'center', alignItems: 'center' }} > {levelLoaded && window.levelConfig && ( <div style={{ width: styleWidth, height: styleHeight, position: 'relative' }}> {/* Render interactive click areas for slots */} {Object.entries(window.levelConfig.slots) .slice( window.levelConfig.ui.upgradesPages[upgradesPage].slots[0], window.levelConfig.ui.upgradesPages[upgradesPage].slots[1] + 1 ) .map(([slotName, slotData]) => ( <div key={slotName} // Use slotName as key id={slotName} style={styleFromPosition(slotData.clickPosition.concat([1,1]))} // clickPosition is [x,y], add dummy width/height for styleFromPosition to work onClick={() => { if (activeSlots[slotName] == null) { // If slot is not yet active (not purchased) if (slotData.price <= playerMoney) { setPurchaseAnimation(slotName); setPlayerMoney((prev) => prev - slotData.price); setActiveSlots((prev) => ({ ...prev, [slotName]: true })); } } else { // Slot is active, attempt to upgrade const currentUpgradeLevel = slotsUpgrades[slotName] ?? 0; const nextUpgradePrice = (window.levelConfig!.slotsUpgrades[currentUpgradeLevel]?.price ?? Infinity) * slotData.upgradesMultiplier;
if (nextUpgradePrice <= playerMoney) { setPurchaseAnimation(slotName); setPlayerMoney((prev) => prev - nextUpgradePrice); setSlotsUpgrades((prev) => ({ ...prev, [slotName]: (prev[slotName] ?? 0) + 1 })); } } }} ></div> ))} {/* Render interactive click areas for upgrades UI page navigation */} {window.levelConfig.ui.upgradesPages.map((upgradesPageConfig, index) => ( <div key={`upgradesPage_${index}`} id={`upgradesPage_${index}`} style={styleFromPosition(upgradesPageConfig.position.concat([1,1]))} // PositionTurple is [x,y,w,h,x,y,w,h], styleFromPosition expects [x,y,w,h] onClick={() => { setUpgradesPage(index); }} ></div> ))} </div> )} </div> <div className={isMobile ? `!mt-0 h-[203px] w-[360px]` : `!mt-0 h-[405px] w-[720px]`} style={{ zIndex: 1, position: 'absolute', top: 0, left: 0, display: 'flex', justifyContent: 'center', alignItems: 'center' }} > <canvas id="game-canvas" ref={canvasRef} // Assign ref to canvas width="3840" // Internal drawing resolution height="2160" ></canvas> </div> </div> </div> );};
export default Game;
Understanding the Code
Section titled “Understanding the Code”Let’s break down the key parts of the src/Game.tsx
file:
useState
Hooks:isMobile
: Detects mobile screen size for responsive adjustments.level
: The currently active game level (fixed to ‘farm’).levelLoaded
: Boolean to indicate if all game assets have been successfully loaded.levelImages
: Stores the loadedHTMLImageElement
objects for rendering.activeSlots
: Tracks which slots the player has purchased. This, along withslotsUpgrades
,dayCycle
, andplayerMoney
, is initialized fromlocalStorage
to persist game state across sessions.purchaseAnimation
: Manages a temporary state to trigger a visual animation when an item is purchased or upgraded.upgradesPage
: Controls which page of the upgrades UI is currently displayed.
useRef
Hooks:canvasRef
: Provides a direct reference to the<canvas/>
DOM element for drawing.animationFrameRef
,animationLoopId
,purchaseAnimationFrameRef
,dayLoopId
: TheseuseRef
instances are crucial for managing the game’s animation and game loops (setInterval
IDs and animation frame counters) without causing excessive re-renders, while still allowing access and modification across renders.
- Dimension Calculations (
innerWidth
,innerHeight
,aspectWidth
,aspectHeight
,styleWidth
,styleHeight
):- These variables dynamically calculate the appropriate scaling factors and display sizes for the canvas and UI elements based on the screen’s aspect ratio and whether it’s a mobile device. This ensures the game scales correctly on different devices.
styleFromPosition
andupdateUi
useCallback
functions:styleFromPosition
: A memoized helper that converts abstract grid positions from thefarm
config into actual pixel values for CSS positioning.updateUi
: A memoized function responsible for drawing all the UI elements (upgrades menu, money display, time/day cycle) onto the canvas, utilizing thelevelImages
and current game state.
useEffect
Hooks (Game Logic Flow):- Resize Listener: The first
useEffect
adds and cleans up a resize event listener to updateisMobile
. - Asset Loading: A critical
useEffect
loads all game image assets (states, animation frames, UI elements, font) asynchronously usingPromise.all
. It setslevelLoaded
totrue
once all assets are ready, allowing the game loops to begin. This effect handles dynamic URLs, making it easier to integrate placeholder images or eventually local assets. - Main Animation Loop: This
useEffect
sets up thesetInterval
for the core game animation. It clears the canvas, draws the background, active slots (with their current growth state), and animation frames. It also handles the visual feedback for purchases/upgrades and callsupdateUi
to render score and time. - Day Cycle Loop: A separate
useEffect
manages the game’s day cycle, which triggers profit calculation at the end of each full cycle (day 12). - State Persistence: Another
useEffect
saves the currentactiveSlots
,slotsUpgrades
,dayCycle
, andplayerMoney
tolocalStorage
whenever these states change, ensuring the player’s progress is saved.
- Resize Listener: The first
- JSX Structure:
- The main game area is composed of two overlapping
div
elements: one for the interactive click areas (zIndex: 2) and another for the actual canvas drawing (zIndex: 1). - Interactive click areas (
div
elements withonClick
handlers) are dynamically rendered based on thelevelConfig
. These invisiblediv
s overlay the canvas and handle the game’s core interactions: purchasing new slots or upgrading existing ones, and navigating upgrades pages. - The
<canvas/>
element is where all the game’s visual elements are drawn using its 2D rendering context.
- The main game area is composed of two overlapping
5. Update src/App.tsx
Section titled “5. Update src/App.tsx”Modify src/App.tsx
to render your Game component:
import React from 'react'import Game from './Game' // Import your Game componentimport './index.css' // Ensure your Tailwind CSS is imported
function App() { return ( <div className="flex min-h-screen flex-col items-center justify-center bg-gray-900 p-4"> <Game /> </div> )}
export default App
6. Run Your Application
Section titled “6. Run Your Application”Start the development server:
npm run dev
Open your web browser and navigate to the address shown in your terminal (usually http://localhost:5173). You should see your farming game running with placeholder images.
Understanding the project purpose
Section titled “Understanding the project purpose”This application demonstrates a powerful concept: building a complete interactive game within a web browser using React and HTML Canvas. It showcases how to:
- Manage complex game state with React hooks.
- Dynamically load and manage multiple image assets for animations and UI.
- Draw intricate game scenes directly onto an HTML canvas.
- Implement game loops (animation, day cycle) using
setInterval
with proper cleanup. - Handle user interactions (clicks) for game mechanics like purchasing and upgrading.
- Persist game data using
localStorage
, allowing players to save their progress. - Design a game that is responsive to different screen sizes. This project serves as an excellent foundation for more complex browser-based game development.
Next Steps
Section titled “Next Steps”- Implement Real Assets: Replace the placehold.co URLs with your actual game sprites and place them in the public/media/Farm directory. This is crucial for the game’s visual appeal.
- Improve UI/UX:
- Add visual feedback (e.g., text overlays, subtle animations) for when purchases or upgrades are not affordable.
- Create actual UI elements for the upgrades pages instead of just invisible click areas, displaying prices and upgrade benefits.
- Consider adding sound effects for purchases, profits, and other events.
- Expand Game Mechanics:
- Introduce different types of levels with unique slots and assets.
- Add more complex upgrade trees or item interactions.
- Implement an inventory system for collected items.
- Add challenges, quests, or achievements.
- Performance Optimization: For more complex games, consider:
- Off-screen Canvas/Web Workers: Perform heavy drawing calculations or game logic in a Web Worker to keep the main thread free and UI responsive.
- Sprite Sheet Optimization: Ensure your asset sprite sheets are efficiently packed.
- Drawing Optimizations: Reduce redundant drawing operations on the canvas.
- Error Handling: Implement more robust error handling for asset loading and game logic.
- Authentication & Cloud Save: For a persistent multiplayer or cross-device experience, integrate a backend (e.g., Firebase Firestore as suggested by the platform instructions) to save game state in the cloud instead of just
localStorage
.
Project Showcase
Section titled “Project Showcase”Visual appearance of the project in guide and showcase can be different