چگونه با React ،Redux و Redux Saga یک بازی مار بسازیم؟

Build A Snake Game with React

25 اردیبهشت 1401
snake-game-with-react

در این مقاله ساخت بازی مار با استفاده از برنامه React را یاد خواهیم گرفت. این یک بازی دو بعدی ساده است که با استفاده از TypeScript ساخته شده است و برای ساخت آن نیازی به استفاده از کتابخانه های اضافی نخواهیم داشت. تصویر چیزی که خواهیم ساخت در زیر آمده است:

بازی مار با React

بازی Snake یا مار یک بازی سرگرم کننده است که ممکن است آن را در تلفن های همراه قدیمی مانند مدل های نوکیا 3310 بازی کرده باشید.

مفهوم پشت آن ساده است. مار در داخل یک جعبه پرسه می‌زند، و هنگامی که میوه/شی را می‌ خورد، امتیاز شما افزایش می‌یابد و مار رشد می‌کند. اگر مار به مرزهای جعبه بازی برخورد کند یا با خودش برخورد کند، بازی تمام می شود.

این مقاله تمام مهارت ها و مراحل لازم برای ایجاد بازی Snake خود را از ابتدا در اختیار شما قرار می دهد. ابتدا ساختار کد و منطق آن ها را بررسی خواهیم کرد. سپس توضیح خواهم داد که وقتی همه کد ها در کنار هم قرار می گیرند و به نوعی متصل می شوند چگونه کار می کنند.

بدون هیچ مقدمه ای، بیایید شروع کنیم.

فهرست مطالب

  • پیش نیازها
  • بازی مار چیست؟ قرار است از چه چیزی در آن استفاده کنیم؟
  • redux چیست؟ چرا از آن استفاده می کنیم؟
  • redux-saga چیست؟ چرا از آن استفاده می کنیم؟
  • استفاده از تعریف حالت
  • راه اندازی برنامه و لایه داده
  • درک لایه UI
  • صفحه Canvas
  • کشیدن اشیا
  • حرکت مار در سراسر صفحه
  • نمایش میوه در یک موقعیت تصادفی
  • محاسبه امتیاز
  • کامپوننت دستورالعمل
  • بازی نهایی
  • خلاصه

پیش نیازهای ساخت بازی مار با React

پیش از شروع خواندن این مقاله، باید دانش اولیه ای از موضوع های زیر داشته باشید:

نمودارهای کلاس: ما می خواهیم از آنها برای نمایش مثال خود استفاده کنیم. در این جا چند منبع وجود دارد که می توانید برای کسب اطلاعات بیشتر در مورد آنها استفاده کنید:

 

ژنراتورها:

مار بازی، یک بازی ویدیویی یا آرکید (بازی آرکید یک نوع بازی تصویری کامپیوتری است که توسط ماشین هایی با سکه پول کار می کنند) است که شامل حرکت یک مار در داخل جعبه است. امتیاز شما بر اساس تعداد شی/میوه ای که مار می خورد افزایش می یابد. این کار باعث افزایش سایز مار نیز می شود. اگر با خودش یا به مرز جعبه برخورد کند، بازی تمام می شود.

اطلاعات بیش تر در مورد تاریخچه یا ریشه های بازی را می توانید در این لینک ویکی پدیا بخوانید.

ما قصد داریم از ابزارهای زیر برای ساخت بازی خود استفاده کنیم:

  • Redux: برای ایجاد و مدیریت state سراسری برای برنامه.
  • Redux-saga: یک میان‌افزار redux که برای مدیریت وظایف async استفاده می‌کنیم.
  • تگ canvas: از آن برای ترسیم یک شی مانند مار و میوه استفاده می کنیم.
  • React: کتابخانه UI.
  • Chakra-UI: کتابخانه کامپوننت.

Redux چیست؟ چرا از آن استفاده می کنیم؟

  • Redux یک نگهدارنده state برای state ها است که به شما کمک می کند state سراسری را برای برنامه خود ساخته و مدیریت کنید. Redux از چند بخش اساسی تشکیل شده است مانند:
  • state سراسری
  • Redux store
  • action ها و سازندگان action ها
  • reducer ها

می توانید همه چیز را در مورد موضوعات بالا و نحوه عملکرد داخلی Redux از بخش getting started در Redux doc بیاموزید.

ما از کتابخانه مدیریت state یعنی Redux استفاده می کنیم زیرا به ما کمک می کند تا state سراسری خود را به روشی ساده تر مدیریت کنیم. Redux به ما امکان می دهد از حفاری prop خودداری کنیم. (حفاری prop فرآیند فرستادن prop ها از یک کامپوننت سطح بالاتر به یک کامپوننت سطح پایین است). هم چنین به ما این امکان را می‌دهد تا از طریق میان‌افزار یا middleware، کارهای پیچیده همگام‌سازی را انجام دهیم.

در این جا می توانید درباره میان افزار بیشتر بدانید.

redux-saga چیست؟ چرا از آن استفاده می کنیم؟

Redux-saga یک میان‌افزار است که به ما کمک می‌کند بین action فرستاده ‌شده و ریدوسر store redux تبادل داده داشته باشیم. به ما امکان می‌دهد تا عوارض جانبی خاصی را بین بین action فرستاده ‌شده و ریدوسر انجام دهیم، مانند واکشی داده، گوش دادن به کارهای خاص یا تنظیم اشتراک‌ها (subscription ها)، ساخت action ها و موارد دیگر.

Redux-saga از ژنراتور ها و توابع ژنراتور استفاده می کند. یک saga معمولی به شکل زیر است:

function* performAction() {
    yield put({
        type: COPY_DATA,
        payload: "Hello"
    });
}

performAction یک تابع ژنراتور است. این تابع ژنراتور تابع put را اجرا خواهد کرد. یک شی ایجاد می کند و آن را به saga برمی گرداند و می گوید که چه نوع action باید با چه payload اجرا شود. سپس فراخوانی put یک توصیفگر شی را برمی‌گرداند که می‌گوید کدام saga می‌تواند در  آینده آن را بگیرد و یک action خاص را اجرا کند.

توجه: با مراجعه به بخش پیش نیاز می توانید در مورد ژنراتورها و تابع های ژنراتور اطلاعات بیشتری کسب کنید.

اکنون این سوال پیش می آید که چرا از میان افزار redux-saga استفاده می کنیم؟ پاسخ ساده است:

  1. راه بهتری برای نوشتن حالت های unit test ارائه می دهد، که به ما کمک می کند تابع های مولد را به روشی ساده تر آزمایش کنیم.
  2. می تواند به شما در انجام بسیاری از عوارض جانبی کمک کند و کنترل بهتری بر تغییرات ایجاد کند. یک مثال این است که هر زمان که می‌خواهید اگر یک عمل X اجرا می‌شود، عمل Y انجام شود، به شما کمک می کند. در بخش بعدی بیشتر در این مورد بحث خواهیم کرد.

اگر با redux-saga آشنایی ندارید، به شدت توصیه می‌کنم اسناد را در این جا مرور کنید.

استفاده از تعریف حالت

توجه: نمودارهای کانتکس، کانتینر و کلاس ترسیم شده در این پست وبلاگ دقیقا از قراردادهای دقیق این نمودارها پیروی نمی کنند. من آن ها را در اینجا تقریب زدم تا بتوانید مفاهیم اساسی را درک کنید.

پیش از شروع، پیشنهاد می‌کنم در مورد مدل‌های c4، نمودارهای کانتینر و نمودارهای زمینه مطالعه کنید. می توانید منابعی در مورد آن ها را در بخش پیش نیازها پیدا کنید.

تعریف حالت کاملا توضیحی است، و ما در بالا درباره آن چه که بازی مار به آن نیاز دارد گفتگو کرده‌ایم. در زیر نمودار کانتکس برای تعریف حالت ما آمده است:

نمودار کانتکس (زمینه ای) بازی مار

نمودار زمینه ای (کانتکس) ما بسیار ساده است. بازیکن با رابط کاربری هم کنش دارد. بیایید ژرف ‌تر رابط کاربری UI نگهدارنده (کانتینری) را ببینیم و سیستم‌های دیگری را در داخل آن بررسی کنیم:

نمودار کانتینری برای بازی مار

همان طور که از نمودار بالا می بینید، رابط کاربری Game Board ما به دو لایه تقسیم می شود:

  1. لایه رابط کاربری (UI)
  2. لایه داده

لایه UI از اجزای زیر تشکیل شده است:

  1. محاسبه گر امتیاز: یک کامپوننت است که هر زمان که مار میوه را بخورد، امتیاز را نمایش می دهد.
  2. صفحه Canvas: کامپوننتی است که بخش اصلی UI بازی را مدیریت می کند. کارکرد اصلی آن کشیدن مار روی canvas و پاک کردن canvas است.

هم چنین مسئولیت های زیر را بر عهده دارد:

  • تشخیص برخورد مار با خودش یا دیوارهای مرزی را می دهد (تشخیص برخورد).
  • با رویدادهای صفحه کلید به حرکت مار در طول صفحه کمک می کند.
  • وقتی بازی تمام شد، بازی را بازنشانی می‌کند.
  • دستورالعمل ها: دستورالعمل های اجرای بازی را به همراه دکمه تنظیم مجدد ارائه می دهد.
  • Utilities (کمک کنندگان): توابعی هستند که در سرتاسر برنامه هر جا که لازم باشد از آن ها استفاده خواهیم کرد.

حالا بیایید در مورد لایه داده صحبت کنیم. لایه داده از اجزای زیر تشکیل شده است:

  • Redux-saga: مجموعه ای از توابع مولد که اعمال خاصی را انجام می دهند.
  • action ها و سازندگان action ها: مجموعه‌ای از ثابت‌ها و توابع هستند که به فرستادن کارهای مناسب کمک می‌کنند.
  • Reducer ها: به ما کمک می کنند تا به کارهای مختلفی که توسط سازندگان اکشن و saga ها فرستاده می شود پاسخ دهیم.

همه این کامپوننت ها را به طور دقیق بررسی خواهیم کرد و در بخش های بعدی خواهیم دید که چگونه به طور جمعی کار می کنند. ابتدا، بیایید پروژه خود را مقداردهی اولیه کنیم و لایه داده خود را راه اندازی کنیم  یعنی Store Redux.

راه اندازی برنامه و لایه داده

پیش از شروع به درک اجزای بازی خود، اجازه دهید ابتدا برنامه React و لایه داده خود را راه اندازی کنیم.

بازی با React ساخته شده است. من به شدت توصیه می کنم از دستور create-react-app برای نصب تمام موارد لازم برای شروع برنامه React خود استفاده کنید.برای ایجاد یک پروژه CRA(create-react-app) ابتدا باید آن را نصب کنیم. دستور زیر را در ترمینال خود تایپ کنید:

npm install -g create-react-app

توجه: پیش از اجرای این دستور مطمئن شوید که Node.js را در سیستم خود نصب کرده اید. برای نصب این لینک را کلیک کنید.

در مرحله بعد، با ایجاد پروژه خود شروع می کنیم. نام آن را snake می گذاریم. دستور زیر را در ترمینال خود تایپ کنید تا پروژه ایجاد شود:

npx create-react-app snake-game
دستور ساخت بازی

ممکن است چند دقیقه زمان ببرد تا تکمیل شود. پس از تکمیل این کار، با استفاده از دستور زیر به پروژه جدید ایجاد شده خود بروید:

cd snake-game

پس از ورود به پروژه، دستور زیر را برای راه اندازی پروژه تایپ کنید:

npm run start

این دستور یک برگه جدید در مرورگر شما باز می کند که آرم React در صفحه مانند زیر می چرخد:

راه اندازی برنامه React

اکنون راه اندازی اولیه پروژه ما کامل شده است. بیایید لایه داده خود (Store Redux)  را پیکربندی کنیم. لایه داده ما نیاز به نصب بسته های زیر دارد:

  • Redux
  • Redux-Saga

ابتدا اجازه دهید با نصب این بسته ها شروع کنیم. پیش از شروع، مطمئن شوید که در پوشه ای که برنامه در آن قرار دارد هستید. دستور زیر را در ترمینال تایپ کنید:

npm install redux react-redux redux-saga

پس از نصب این بسته ها، ابتدا Store Redux خود را پیکربندی می کنیم. برای شروع، اجازه دهید ابتدا یک پوشه به نام store در پوشه src ایجاد می کنیم. این پوشه store شامل تمام فایل های مربوط به Redux خواهد بود. پوشه store خود را به روش زیر سازماندهی می کنیم:

سازماندهی پوشه src

بیایید ببینیم که هر یک از این فایل ها چه کاری انجام می دهند:

  • actions/index.tsx: این فایل از ثابت هایی تشکیل شده است که نشان دهنده کارهای است که برنامه ما می تواند انجام دهد و آن ها را به Store Redux بفرستد. مثالی از یک چنین ثابتی در زیر آمده است:
export const MOVE_RIGHT = "MOVE_RIGHT"

از این ثابت action برای ایجاد تابعی استفاده می کنیم که یک شی با ویژگی های زیر را برمی گرداند:

  • action: نوع عمل، که یک ثابت است.
  • payload: داده های اضافی که به عنوان داده های برافزوده عمل می کنند.

این توابع که یک شی را با ویژگی type برمی گرداند، Action Creators یا سازندگان action (کنش) نامیده می شوند. از این توابع برای فرستادن کارها به Store Redux خود استفاده می کنیم.

ویژگی payload نشان می دهد که همراه با این عمل می توانیم داده های اضافی را نیز ارسال کنیم که می تواند برای ذخیره یا به روز رسانی مقدار در داخل حالت جهانی استفاده شود.

توجه: بازگرداندن ویژگی type از سازنده action الزامی است. ویژگی payload اختیاری است. هم چنین نام ویژگی payload می تواند هر چیزی باشد. بیایید نمونه ای از یک سازنده action را ببینیم:

//Without payload

export const moveRight = () => ({

type: MOVE_RIGHT

});




//With payload

export const moveRight = (data: string) => ({

type: MOVE_RIGHT,

payload: data

});

اکنون که می‌دانیم اکشن‌ها و سازندگان اکشن چیستند، می‌توانیم به پیکربندی reducer، برویم.

reducer ها توابعی هستند که هر بار که یک اکشن فرستاده می شود یک state سراسری جدید را برمی گرداند. آن ها state سراسری فعلی را می گیرند و state جدید را بر اساس اکشن که فرستاده شده/ فراخوانی می شود، برمی گردانند. این state جدید بر اساس state قبلی محاسبه می شود.

در اینجا باید مراقب باشیم که هیچ اثر جانبی یا side effect در داخل این تابع ایجاد نکنیم. ما نباید state سراسری را تغییر دهیم بلکه باید state به روز شده را به عنوان یک شی جدید برگردانیم. بنابراین، تابع reducer باید یک تابع خالص باشد.

به اندازه کافی در مورد reducer ها صحبت کردیم. بیایید نگاهی به reducer های نمونه خود بیندازیم:

const GlobalState = {

data: ""

};




const gameReducer = (state = GlobalState, action) => {

switch (action.type) {

case "MOVE_RIGHT":

/**

* Perform a certain set of operations

*/

return {

...state, data: action.payload

};




default:

return state;

}

}

در این مثال، ما یک تابع reducer ایجاد کرده ایم که به آن gameReducer می گویند. state (پارامتر پیش‌فرض به‌عنوان یک state سراسری) و یک action را می‌گیرد. هر زمان که action.type داشته باشیم که با یکی از case های switch مطابقت داشته باشد، یک action خاص مانند برگرداندن یک state جدید بر اساس action انجام می دهد.

فایل sagas/index.ts شامل تمام saga هایی است که در برنامه خود استفاده خواهیم کرد. زمانی که اجرای بازی مار را شروع کنیم ژرف تر با توضیح saga ها خواهیم پرداخت.

اکنون درک اولیه ای از Store Redux خود داریم. بیایید ادامه دهیم و stores/index.ts را مانند زیر ایجاد کنیم:

import {

createStore,

applyMiddleware

} from "redux";

import createSagaMiddleware from "redux-saga";

import gameReducer from "./reducers";

import watcherSagas from "./sagas";

const sagaMiddleware = createSagaMiddleware();




const store = createStore(gameReducer, applyMiddleware(sagaMiddleware));




sagaMiddleware.run(watcherSagas);

export default store;

ابتدا reducer و saga خود را import خواهیم کرد. در مرحله بعد از تابع createSagaMiddleware() برای ایجاد میان افزار saga استفاده می کنیم.

سپس، آن را با ارسال آن به عنوان آرگومان به تابع applicationMiddleware در داخل createStore که برای ایجاد فروشگاه از آن استفاده می کنید، به فروشگاه خود متصل می کنیم. همچنین gameReducer را به این تابع منتقل می کنیم تا یک کاهش دهنده به فروشگاه ما نگاشت شود.

در نهایت، ما sagaMiddleware خود را با استفاده از این کد اجرا می کنیم:

sagaMiddleware.run(watcherSagas);

مرحله آخر ما این است که این store  را در سطح بالای برنامه React با استفاده از کامپوننت Provider رندر شده توسط react-redux به اصطلاح تزریق کنیم. شما می توانید این کار را به صورت زیر انجام دهید:

import { Provider } from "react-redux";

import store from "./store";




const App = () => {

return (

<Provider store={store}>

//   Child components...

</Provider>

);

};




export default App;

هم چنین باید chakra-UI را به عنوان یک کتابخانه UI برای پروژه خود نصب کنیم. برای نصب chakra-UI دستور زیر را تایپ کنید:

npm install @chakra-ui/react @emotion/react@^11 @emotion/styled@^11 framer-motion@^5

هم چنین باید ChakraProvider را که در فایل App.tsx ما قرار می گیرد تنظیم کنیم. فایل App.tsx پس از این تغییر به صورت زیر در خواهد آمد:

import { ChakraProvider, Container, Heading } from "@chakra-ui/react";

import { Provider } from "react-redux";

import store from "./store";




const App = () => {

return (

<Provider store={store}>

<ChakraProvider>

<Container maxW="container.lg" centerContent>

<Heading as="h1" size="xl">SNAKE GAME</Heading>

//Children components

</Container>

</ChakraProvider>

</Provider>

);

};




export default App;

درک لایه UI

بیایید ابتدا پویایی بازی Snake خود را از دیدگاه UI درک کنیم. پیش از شروع، تا این جا بازی Snake ما به شکل زیر خواهد بود:

درک لایه UI

لایه UI از 3 لایه تشکیل شده است: محاسبه گر امتیاز، صفحه canvas، و دستورالعمل ها. نمودار زیر این بخش ها را نشان می دهد:

نمودار لایه UI

بیایید عمیق‌تر به هر یک از این بخش‌ها بپردازیم تا بفهمیم بازی Snake ما چگونه کار می‌کند.

صفحه canvas

با درک صفحه canvas شروع می کنیم:

صفحه canvas دارای ابعاد height 600، width 1000 است.همه این صفحه به بلوک هایی با اندازه 20x20 تقسیم شده است. یعنی هر چیزی که روی این canvas کشیده می شود دارای ارتفاع 20 و عرض 20 خواهد بود. از تگ canvas  برای ترسیم اشکال در کامپوننت استفاده می کنیم.

در پروژه ما، کامپوننت صفحه canvas را در داخل فایل components/CanvasBoard.tsx می‌نویسیم. اکنون که درک اولیه ما در مورد کامپوننت CanvasBoard روشن شده است، بیایید ساخت این کامپوننت را شروع کنیم. یک کامپوننت ساده ایجاد می کنیم که یک عنصر canvas را به صورت زیر برمی گرداند:

export interface ICanvasBoard {

height: number;

width: number;

}




const CanvasBoard = ({ height, width }: ICanvasBoard) => {

return (

<canvas

style={{

border: "3px solid black",

}}

height={height}

width={width}

/>

);

};

این کامپوننت را در فایل App.tsx با عرض و ارتفاع 1000 و 600 به عنوان prop مانند زیر فراخوانی کنید:

import { ChakraProvider, Container, Heading } from "@chakra-ui/react";

import { Provider } from "react-redux";

import CanvasBoard from "./components/CanvasBoard";

import ScoreCard from "./components/ScoreCard";

import store from "./store";




const App = () => {

return (

<Provider store={store}>

<ChakraProvider>

<Container maxW="container.lg" centerContent>

<Heading as="h1" size="xl">SNAKE GAME</Heading>

<CanvasBoard height={600} width={1000} /> //Canvasboard component added

</Container>

</ChakraProvider>

</Provider>

);

};




export default App;

این کد یک border ساده با height=600 و width=1000  با border سیاه مانند زیر ایجاد می کند:

snake game

حالا بیایید یک مار در مرکز این canvas بکشیم. اما پیش از شروع طراحی، باید context این تگ canvas را بدست آوریم.

context یک تگ canvas تمام اطلاعات مورد نیاز مربوط به این تگ canvas را در اختیار شما قرار می دهد. ابعاد canvas را به شما می دهد و هم چنین به شما کمک می کند تا روی canvas نقاشی بکشید.

برای بدست آوردن context یک تگ canvas باید تابع getCanvas('2d') را فراخوانی کنیم که context 2 بعدی canvas را برمی گرداند. نوع برگشتی این تابع یک interface برای  CanvasRenderingContext2D است. برای انجام این کار در JS ساده، کاری مانند زیر انجام می دهیم:

const canvas = document.querySelector('canvas');

const canvasCtx = canvas.getContext('2d');

اما برای انجام این کار در React باید یک ref ایجاد کنیم و آن را به تگ canvas بفرستیم تا بتوانیم بعدا در هوک های مختلف از آن استفاده کنیم. برای انجام این کار در برنامه ما، با استفاده از هوک useRef یک ref ایجاد کنید:

const canvasRef = useRef<HTMLCanvasElement | null>(null);

ref را به تگ canvas خود می فرستیم:

<canvas
  ref={canvasRef}
  style={{
    border: "3px solid black",
  }}
  height={height}
  width={width}
/>;

هنگامی که canvasRef به تگ canvas فرستاده داده می شود، می توانیم آن را در داخل یک هوک useEffect استفاده کنیم و متن را در یک متغیر state ذخیره کنیم.

export interface ICanvasBoard {

height: number;

width: number;

}




const CanvasBoard = ({ height, width }: ICanvasBoard) => {

const canvasRef = (useRef < HTMLCanvasElement) | (null > null);

const [context, setContext] =

(useState < CanvasRenderingContext2D) | (null > null);




useEffect(() => {

//Draw on canvas each time

setContext(canvasRef.current && canvasRef.current.getContext("2d")); //store in state variable

}, [context]);




return (

<canvas

ref={canvasRef}

style={{

border: "3px solid black",

}}

height={height}

width={width}

/>

);

};

کشیدن اشیا

پس از دریافت context، باید هر بار که یک کامپوننت به روز می شود، وظایف زیر را انجام دهیم:

  1. canvas را پاک کنیم
  2. مار را با موقعیت فعلی بکشیم
  3. یک میوه را در یک موقعیت تصادفی در داخل جعبه بکشیم

می خواهیم چندین بار canvas را پاک کنیم، بنابراین این را به یک تابع کاربردی تبدیل می کنیم. بنابراین برای آن، اجازه دهید یک پوشه به نام utilities ایجاد کنیم:

mkdir utilities

cd utilities

touch index.tsx

دستور بالا هم چنین یک فایل index.tsx در داخل پوشه utilities ایجاد می کند. کد زیر را در فایل utilities/index.tsx بیافزایید:

export const clearBoard = (context: CanvasRenderingContext2D | null) => {

if (context) {

context.clearRect(0, 0, 1000, 600);

}

};

عملکرد clearBoard بسیار ساده است. اقدامات زیر را انجام می دهد:

  • اشیا کانتکس canvas2d را به عنوان آرگومان می پذیرد.
  • بررسی می‌کند که متن خالی یا تعریف نشده نباشد.
  • تابع clearRect تمام پیکسل ها یا اشیا موجود در مستطیل را پاک می کند. این تابع عرض و ارتفاع را به عنوان آرگومان برای پاک کردن مستطیل می گیرد.

ما از این تابع clearBoard در داخل کامپوننت CanvasBoard در  useEffect خود برای پاک کردن canvas هر بار که کامپوننت به‌روزرسانی می‌شود استفاده می‌کنیم. برای تمایز بین useEffect های مختلف، useEffect فوق را useEffect1 نام گذاری می کنیم.

حالا بیایید با ترسیم مار و میوه در یک موقعیت تصادفی شروع کنیم. از آن جایی که قرار است چندین بار اشیا را ترسیم کنیم، یک تابع کاربردی به نام drawObject برای آن ایجاد می کنیم. کد زیر را در فایل utilities/index.tsx اضافه می کنیم:

export interface IObjectBody {

x: number;

y: number;

}




export const drawObject = (

context: CanvasRenderingContext2D | null,

objectBody: IObjectBody[],

fillColor: string,

strokeStyle = "#146356"

) => {

if (context) {

objectBody.forEach((object: IObjectBody) => {

context.fillStyle = fillColor;

context.strokeStyle = strokeStyle;

context?.fillRect(object.x, object.y, 20, 20);

context?.strokeRect(object.x, object.y, 20, 20);

});

}

};

تابع بالا برای کشیدن یک شی بر روی canvas است

تابع drawObject آرگومان های زیر را می پذیرد:

context - یک شی کانتکس دوبعدی canvas برای ترسیم شی روی canvas.

objectBody - آرایه ای از اشیا است که هر شی دارای ویژگی های x و y است، مانند interface به نام IObjectBody

fillColor - رنگی که باید در داخل شی استفاده شود.

strokeStyle - رنگی که باید برای outline شی استفاده شود که دارای مقدار پیش‌فرض #146356 است.

این تابع بررسی می‌کند که آیا کانتکس تعریف نشده یا null است. سپس با استفاده از forEach روی objectBody تکرار می شود. برای هر شی عملیات زیر را انجام می دهد:

  • fillStyle و strokeStyle را در داخل کانتکس مقداردهی می کند.
  • از fillReact برای ایجاد یک مستطیل تماما رنگ شده با مختصات x و object.y با اندازه 20x20 استفاده می کند.
  • در نهایت، از strokeRect برای ایجاد یک مستطیل مشخص با مختصات x و object.y با اندازه 20x20 استفاده می کند.

برای کشیدن مار باید موقعیت مار را حفظ کنیم. برای آن، می‌توانیم از ابزار مدیریت state سراسری redux خود استفاده کنیم.ما باید فایل Reducers/index.ts خود را به روز کنیم. از آن جایی که می خواهیم موقعیت مار را ردیابی کنیم، آن را به صورت زیر به state سراسری خود اضافه می کنیم:

interface ISnakeCoord {

x: number;

y: number;

}




export interface IGlobalState {

snake: ISnakeCoord[] | [];

}




const globalState: IGlobalState = {

//Postion of the entire snake

snake: [

{ x: 580, y: 300 },

{ x: 560, y: 300 },

{ x: 540, y: 300 },

{ x: 520, y: 300 },

{ x: 500, y: 300 },

],

};

در بالا  state سراسری به روزآوری می شود.این state را در کامپوننت CanvasBoard خود فراخوانی می کنیم. ما از هوک useSelector از react-redux برای دریافت state مورد نیاز از store استفاده خواهیم کرد. موارد زیر state سراسری مار را به ما نشان می دهد:

const snake1 = useSelector((state: IGlobalState) => state.snake);

بیایید این را در کامپوننت Canvas Board خود قرار دهیم و آن را به تابع drawObject  خود بفرستیم و خروجی را ببینیم:

//Importing necessary modules

import { useSelector } from "react-redux";

import { clearBoard, drawObject, generateRandomPosition } from "../utils";




export interface ICanvasBoard {

height: number;

width: number;

}




const CanvasBoard = ({ height, width }: ICanvasBoard) => {

const canvasRef = useRef<HTMLCanvasElement | null>(null);

const [context, setContext] = useState<CanvasRenderingContext2D | null>(null);

const snake1 = useSelector((state: IGlobalState) => state.snake);

const [pos, setPos] = useState<IObjectBody>(

generateRandomPosition(width - 20, height - 20)

);




useEffect(() => {

//Draw on canvas each time

setContext(canvasRef.current && canvasRef.current.getContext("2d")); //store in state variable

drawObject(context, snake1, "#91C483"); //Draws snake at the required position

drawObject(context, [pos], "#676FA3"); //Draws fruit randomly

}, [context])




return (

<canvas

style={{

border: "3px solid black",

}}

height={height}

width={width}

/>

);

};

کد بالا برای کشیدن مار و میوه است. بیایید ببینیم وقتی مار کشیده می شود خروجی چگونه خواهد بود:

کشیدن مار

حرکت مار در سراسر صفحه

اکنون که مار خود را کشیده ایم، بیایید یاد بگیریم که چگونه مار را حرکت دهیم.

حرکت مار ساده است. همیشه باید نکات زیر را رعایت کنید:

  • اگر مار به صورت افقی حرکت می کند، فقط می تواند به سمت بالا، پایین و در جهت حرکت فعلی حرکت کند. به عنوان مثال، اگر مار به سمت راست حرکت می کند، می تواند به سمت بالا یا پایین حرکت کند یا به حرکت خود به سمت راست ادامه دهد.
  • اگر مار به صورت عمودی در حال حرکت باشد، فقط می تواند به سمت راست، چپ حرکت کند یا در جهتی که در حال حاضر در حال حرکت است ادامه دهد. به عنوان مثال، اگر مار در حال حرکت به سمت بالا باشد، می تواند به سمت راست یا چپ حرکت کند (یا به سمت بالا ادامه دهد).
  • مار نمی تواند در جهت مخالف جهت فعلی حرکت کند. یعنی اگر مار به سمت چپ حرکت کند، نمی تواند بلافاصله به سمت راست حرکت کند. به همین ترتیب، اگر بالا می رود، نمی تواند به سمت پایین حرکت کند.

برای حرکت روان مار، مار باید همیشه به شکل مستطیلی حرکت کند. و برای داشتن آن حرکت باید نکات بالا را رعایت کند.نمودار زیر به طور خلاصه نحوه عملکرد حرکت مار در کل برنامه را نشان می دهد:

نمودار چگونگی حرکت مار

توجه: در نمودار بالا، کل حرکت مار با کامپوننت CanvasBoard شروع می شود.

نکته: اگر نمی توانید نمودار بالا را دنبال کنید نگران نباشید. بخش های بعدی را بخوانید تا درک بیشتری به دست آورید.

برای حفظ حرکت مار، state دیگری به نام disallowedDirection را در state سراسری یا عمومی خود معرفی می کنیم. هدف این متغیر پیگیری جهت مخالف حرکت مار است.

به عنوان مثال اگر مار به سمت چپ حرکت می کند، DisallowedDirection به سمت راست تنظیم می شود. بنابراین به طور خلاصه، ما این جهت را دنبال می کنیم تا بتوانیم از حرکت مار در جهت مخالف خود جلوگیری کنیم.

بیایید این متغیر را در state سراسری خود ایجاد کنیم:

interface ISnakeCoord {
  x: number;
  y: number;
}

export interface IGlobalState {
  snake: ISnakeCoord[] | [];
  disallowedDirection: string;
}

const globalState: IGlobalState = {
    //Postion of the entire snake
  snake: [
    { x: 580, y: 300 },
    { x: 560, y: 300 },
    { x: 540, y: 300 },
    { x: 520, y: 300 },
    { x: 500, y: 300 },
  ],
    disallowedDirection: ""
};

حالا بیایید چند اکشن و سازنده اکشن بسازیم که به حرکت دادن مار کمک می کند. برای این مورد دو نوع اکشن خواهیم داشت:

  • اکشن ها برای saga
  • این ها اکشن هایی هستند که از کامپوننت CanvasBoard فرستاده می شوند. این اکشن ها عبارتند از:
    • MOVE_RIGHT
    • MOVE_LEFT
    • MOVE_UP
    • MOVE_DOWN
  • اکشن ها برای reducer ها
  • این ها اکشن هایی هستند که توسط saga برای فرستادن فراخوانی ها به reducer ها انجام می شود. این اکشن ها عبارتند از:
    • RIGHT
    • LEFT
    • UP
    • DOWN

در بخش های بعدی به این اکشن ها نگاه دقیق تری خواهیم داشت. یک اکشن دیگر به نام SET_DIS_DIRECTION ایجاد خواهیم کرد تا استیت DisallowedDirection را مقداردهی کنیم.

بیایید چند سازنده اکشن برای حرکت مار ایجاد کنیم:

setDisDirection – این سازنده اکشن برای مقداردهی DisallowedDirection از طریق اکشن SET_DIS_DIRECTION استفاده می شود. در زیر کد این سازنده اکشن آمده است:

export const setDisDirection = (direction: string) => ({

type: SET_DIS_DIRECTION,

payload: direction

});

makeMove - برای تنظیم یا به‌روزرسانی مختصات جدید مار با به‌روزرسانی متغیر استیت snake استفاده می‌شود. در زیر کد این سازنده اکشن آمده است:

export const makeMove = (dx: number, dy: number, move: string) => ({

type: move,

payload: [dx, dy]

});

پارامترهای dx و dy دلتا هستند. آن ها به Store Redux می گویند که چقدر باید مختصات هر بلوک مار را افزایش یا کاهش دهیم تا مار را در جهت معین حرکت دهیم. از پارامتر move برای تعیین جهت حرکت مار استفاده می شود. به زودی در بخش‌های آینده نگاهی به این سازندگان اکشن‌ها خواهیم داشت.

در نهایت، فایل actions/index.ts چیزی شبیه به زیر خواهد بود:

export const MOVE_RIGHT = "MOVE_RIGHT";

export const MOVE_LEFT = "MOVE_LEFT";

export const MOVE_UP = "MOVE_UP";

export const MOVE_DOWN = "MOVE_DOWN";




export const RIGHT = "RIGHT";

export const LEFT = "LEFT";

export const UP = "UP";

export const DOWN = "DOWN";




export const SET_DIS_DIRECTION = "SET_DIS_DIRECTION";




export interface ISnakeCoord {

x: number;

y: number;

}

export const makeMove = (dx: number, dy: number, move: string) => ({

type: move,

payload: [dx, dy]

});




export const setDisDirection = (direction: string) => ({

type: SET_DIS_DIRECTION,

payload: direction

});

حال، بیایید نگاهی به منطقی که برای حرکت دادن مار بر اساس اکشن های بالا استفاده می کنیم بیندازیم. تمام حرکات مار با اکشن های زیر ردیابی می شود:

  • RIGHT
  • LEFT
  • UP
  • DOWN

همه این اکشن ها برای حرکت مار ضروری هستند. این اکشن ها، هنگامی که ارسال می شوند، همیشه state سراسری مار را بر اساس منطقی که در زیر توضیح می دهیم به روز می کنند. و آنها مختصات جدید مار را در هر حرکت محاسبه می کنند.

برای محاسبه مختصات جدید مار بعد از هر حرکت، از منطق زیر استفاده می کنیم:

  1. مختصات را در یک متغیر جدید به نام newSnake کپی می کنیم:
  2. شروع newSnake را با مختصات x و y جدید مقداردهی می کنیم. این مختصات با افزودن مقادیر x و y از payload به‌روزرسانی (update) می‌شوند.
  3. در نهایت آخرین ورودی را از آرایه newSnake حذف می کنیم.

اکنون که درک درستی از نحوه حرکت مار داریم، بیایید موارد زیر را در Reducer خود اضافه کنیم:

case RIGHT:
    case LEFT:
    case UP:
    case DOWN: {
      let newSnake = [...state.snake];
      newSnake = [{
        //New x and y coordinates
        x: state.snake[0].x + action.payload[0],
        y: state.snake[0].y + action.payload[1],
      }, ...newSnake];
      newSnake.pop();

      return {
        ...state,
        snake: newSnake,
      };
    }

برای هر حرکت مار، مختصات x و y جدید را به روز می کنیم که توسط payload های action.payload[0] و action.payload[1]  افزایش می یابد. راه‌اندازی اکشن‌ها، سازندگان اکشن‌ها و منطق reducer را با موفقیت به پایان رساندیم. ما آماده هستیم و اکنون می توانیم از همه این ها در کامپوننت CanvasBoard خود استفاده کنیم.

ابتدا، اجازه دهید یک هوک useEffect را در کامپوننت CanvasBoard خود اضافه کنیم. ما از این هوک برای پیوست کردن/افزودن یک کنترل کننده رویداد استفاده خواهیم کرد. این کنترل کننده رویداد به فشار کلید رویداد متصل می شود. ما از این رویداد استفاده می کنیم زیرا هر زمان که کلیدهای w a s d را فشار می دهیم باید بتوانیم حرکت مار را کنترل کنیم.

useEffect ما چیزی شبیه به زیر خواهد بود:

useEffect(() => {

window.addEventListener("keypress", handleKeyEvents);




return () => {

window.removeEventListener("keypress", handleKeyEvents);

};

}, [disallowedDirection, handleKeyEvents]);

به روش زیر کار می کند:

  1. هنگام نصب کامپوننت، شنونده رویداد با تابع handleKeyEvents به شی window متصل می شود.
  2. با جدا کردن کامپوننت، شنونده رویداد از شی window حذف می شود.
  3. اگر تغییری در جهت یا تابع handleKeyEvents ایجاد شود، useEffect را دوباره اجرا می کنیم. بنابراین، DisallowedDirection و handleKeyEvents را به آرایه وابستگی اضافه کرده‌ایم.

بیایید نگاهی به نحوه ایجاد تماس handleKeyEvents بیندازیم. در زیر کد مربوط به همین مورد است:

const handleKeyEvents = useCallback(

(event: KeyboardEvent) => {

if (disallowedDirection) {

switch (event.key) {

case "w":

moveSnake(0, -20, disallowedDirection);

break;

case "s":

moveSnake(0, 20, disallowedDirection);

break;

case "a":

moveSnake(-20, 0, disallowedDirection);

break;

case "d":

event.preventDefault();

moveSnake(20, 0, disallowedDirection);

break;

}

} else {

if (

disallowedDirection !== "LEFT" &&

disallowedDirection !== "UP" &&

disallowedDirection !== "DOWN" &&

event.key === "d"

)

moveSnake(20, 0, disallowedDirection); //Move RIGHT at start

}

},

[disallowedDirection, moveSnake]

);


این تابع را با یک هوک useCallback نوشته ایم. به این دلیل که ما نسخه ذخیره‌سازی شده این تابع را می‌خواهیم که در هر تغییر state (یعنی در تغییر DisallowedDirection و moveSnake) فراخوانی شود. این تابع با هر کلیدی که روی صفحه کلید فشار داده می شود فراخوانی می شود.

این تابع کار زیر را انجام می دهد:

  • اگر DisallowedDirection خالی باشد، مطمئن می شویم که بازی فقط زمانی شروع می شود که کاربر کلید d را فشار دهد. به این معنی که بازی فقط زمانی شروع می شود که مار به سمت راست حرکت کند.

توجه: در ابتدا مقدار DisallowedDirection یک رشته خالی است. به این ترتیب، می دانیم که اگر مقدار آن خالی باشد، بازی در حالت شروع است.

پس از شروع بازی، DisallowedDirection خالی نخواهد بود و سپس به تمام فشارهای صفحه کلید مانند w s و a گوش می دهد.

در نهایت، در هر فشار کلید، تابعی به نام moveSnake را فراخوانی می کنیم. در بخش بعدی به بررسی دقیق تر آن خواهیم پرداخت.

تابع moveSnake تابعی است که یک اکشن را به سازنده اکشن makeMove را ارسال می کند. این تابع سه آرگومان را می پذیرد:

  1. dx - دلتا برای محور x. این نشان می دهد که مار چقدر باید در امتداد محور x حرکت کند. اگر dx مثبت باشد به سمت راست و اگر منفی باشد به سمت چپ حرکت می کند.
  2. dy - دلتا برای محور y. این نشان می دهد که مار چقدر باید در امتداد محور y حرکت کند. اگر dy مثبت باشد به سمت پایین حرکت می کند و اگر منفی باشد به سمت بالا حرکت می کند.
  3. DisallowedDirection - این مقدار نشان می دهد که مار نباید در جهت مخالف حرکت کند. این اکشنی است که توسط Saga میان افزار ما ضبط شده است.

کد تابع moveSnake به شکل زیر خواهد بود:

const moveSnake = useCallback(

(dx = 0, dy = 0, ds: string) => {

if (dx > 0 && dy === 0 && ds !== "RIGHT") {

dispatch(makeMove(dx, dy, MOVE_RIGHT));

}




if (dx < 0 && dy === 0 && ds !== "LEFT") {

dispatch(makeMove(dx, dy, MOVE_LEFT));

}




if (dx === 0 && dy < 0 && ds !== "UP") {

dispatch(makeMove(dx, dy, MOVE_UP));

}




if (dx === 0 && dy > 0 && ds !== "DOWN") {

dispatch(makeMove(dx, dy, MOVE_DOWN));

}

},

[dispatch]

);

MoveSnake یک تابع ساده است که شرایط را بررسی می کند:

  1. اگر dx>0، و DisallowedDirection دارای مقدار RIGHT نباشد، می تواند در جهت RIGHT حرکت کند.
  2. اگر dx<0، و DisallowedDirection دارای مقدار LEFT نباشد، می تواند در جهت LEFT حرکت کند.
  3. اگر dy>0، و DisallowedDirection دارای مقدار DOWN نباشد، می تواند در جهت DOWN حرکت کند.
  4. اگر dy<0، و DisallowedDirection دارای مقدار DOWN نباشد، می تواند در جهت بالا حرکت کند.

این مقدار DisallowedDirection در saga ‌های ما مقداردهی شده است که در بخش‌های بعدی این مقاله بیش تر در مورد آن صحبت خواهیم کرد. اگر اکنون تابع handleKeyEvents را دوباره بررسی کنیم، بسیار منطقی تر است. بیایید یک مثال را در اینجا مرور کنیم:

  • فرض کنید می خواهید مار را به سمت راست حرکت دهید. این تابع تشخیص می دهد که کلید d فشرده شده است یا نه.
  • هنگامی که این کلید فشار داده می شود، تابع makeMove شرط شروع بازی را با dx را با 20 و dy را با 0 فراخوانی می کند.

به این ترتیب حرکت مار را در جهت خاصی انجام می دهیم. حال بیایید نگاهی به saga ‌هایی که استفاده کرده‌ایم، و نحوه برخورد آن ها با حرکت مار بیاندازیم.

بیایید یک فایل به نام saga/index.ts ایجاد کنیم. این فایل شامل تمام saga های ما خواهد بود. این یک قانون نیست، اما به طور کلی، ما دو saga ایجاد می کنیم.

اولین مورد saga ای است که اکشن های واقعی را به store می فرستد.اجازه دهید این Saga را worker بنامیم. دوم حماسه ناظر است که به دنبال هر اقدامی است که در حال ارسال است - بیایید این Saga را watcher   بنامیم.

اکنون باید یک ساگای watcher  ایجاد کنیم که مراقب اکشن های زیر باشد: MOVE_RIGHT ،MOVE_LEFT ،MOVE_UP MOVE_DOWN.

function* watcherSaga() {

yield takeLatest(

[MOVE_RIGHT, MOVE_LEFT, MOVE_UP, MOVE_DOWN],

moveSaga

);

}

این ساگا watcher  کنش (اکشن) های بالا را مشاهده می کند و تابع moveSaga را که یک ساگا worker است اجرا می کند.

متوجه خواهید شد که ما از یک تابع جدید به نام takeLatest استفاده کرده ایم. اگر هر یک از اکشن های ذکر شده در آرگومان اول ارسال شود، این تابع ساگا watcher  را فراخوانی می کند و هر فراخوانی ساگای قبلی را لغو می کند.

takeLatest(pattern, saga, ...args)

برای هر اکشنی که با الگوی مطابقت دارد به store ارسال می‌شود، saga ای ایجاد می‌کند و به طور خودکار هر saga قبلی را که قبلا شروع شده بود، در صورتی که هنوز در حال اجرا باشد، لغو می کند.

هر بار که یک اکشن به store فرستاده می شود، اگر این اکشن با الگو مطابقت داشته باشد، takeLatest یک ساگای جدید را در پس‌زمینه شروع می‌کند. اگر یک saga قبلا شروع شده باشد (در آخرین اکشنی که پیش از اکشن واقعی ارسال شده است)، و اگر این کار همچنان در حال اجرا باشد، کار لغو خواهد شد.

الگو (pattern): رشته | آرایه | تابع - برای اطلاعات بیشتر به این نشانی مراجعه کنید.

ساگا: تابع - یک تابع ژنراتور

args (آرایه): آرگومان هایی که باید به کار آغازین ارسال شوند. takeLatest اکشن ورودی را به لیست آرگومان اضافه می کند (یعنی اکشن آخرین آرگومان ارائه شده به saga خواهد بود)

حال بیایید یک ساگای worker  به نام moveSaga ایجاد کنیم که در واقع اکشن ها را به Store Redux ارسال می کند:

export function* moveSaga(params: {

type: string;

payload: ISnakeCoord;

}): Generator<

| PutEffect<{ type: string; payload: ISnakeCoord }>

| PutEffect<{ type: string; payload: string }>

| CallEffect<true>

> {

while (true) {

//dispatches movement actions

yield put({

type: params.type.split("_")[1],

payload: params.payload,

});




//Dispatches SET_DIS_DIRECTION action

switch (params.type.split("_")[1]) {

case RIGHT:

yield put(setDisDirection(LEFT));

break;




case LEFT:

yield put(setDisDirection(RIGHT));

break;




case UP:

yield put(setDisDirection(DOWN));

break;




case DOWN:

yield put(setDisDirection(UP));

break;

}

yield delay(100);

}

}


moveSaga کارهای زیر را انجام می دهد:

  1. در داخل یک حلقه بی نهایت اجرا می شود.
  2. بنابراین هنگامی که یک جهت داده می شود، یعنی اگر کلید d فشار داده شود و اکشن MOVE_RIGHT ارسال شود آن گاه شروع به ارسال همان اکشن می کند تا یک اکشن جدید (یعنی جهت) داده شود. این توسط قطعه زیر مدیریت می شود:
yield put({

type: params.type.split("_")[1],

payload: params.payload,

});
  1. هنگامی که اکشن بالا ارسال شد، جهت غیر مجاز را در جهت مخالف قرار می دهیم که توسط سازنده اکشن setDisDirection مراقبت می شود.

حالا بیایید این حماسه ها را به فایل sagas/index.ts خود اضافه کنیم:

import {

CallEffect,

delay,

put,

PutEffect,

takeLatest

} from "redux-saga/effects";

import {

DOWN,

ISnakeCoord,

LEFT,

MOVE_DOWN,

MOVE_LEFT,

MOVE_RIGHT,

MOVE_UP, RIGHT,

setDisDirection, UP

} from "../actions";




export function* moveSaga(params: {

type: string;

payload: ISnakeCoord;

}): Generator<

| PutEffect<{ type: string; payload: ISnakeCoord }>

| PutEffect<{ type: string; payload: string }>

| CallEffect<true>

> {

while (true) {

yield put({

type: params.type.split("_")[1],

payload: params.payload,

});

switch (params.type.split("_")[1]) {

case RIGHT:

yield put(setDisDirection(LEFT));

break;




case LEFT:

yield put(setDisDirection(RIGHT));

break;




case UP:

yield put(setDisDirection(DOWN));

break;




case DOWN:

yield put(setDisDirection(UP));

break;

}

yield delay(100);

}

}




function* watcherSagas() {

yield takeLatest(

[MOVE_RIGHT, MOVE_LEFT, MOVE_UP, MOVE_DOWN],

moveSaga

);

}




export default watcherSagas;

اکنون بیایید کامپوننت CanvasBoard  را برای هماهنگ شدن با این تغییرها ویرایش کنیم.

کامپوننت CanvasBoard  با حرکت مار به روز می شود:

//Importing necessary modules

import { useSelector } from "react-redux";

import { drawObject, generateRandomPosition } from "../utils";




export interface ICanvasBoard {

height: number;

width: number;

}




const CanvasBoard = ({ height, width }: ICanvasBoard) => {

const canvasRef = useRef < HTMLCanvasElement | null > (null);

const [context, setContext] = useState < CanvasRenderingContext2D | null > (null);

const snake1 = useSelector((state: IGlobalState) => state.snake);

const [pos, setPos] = useState < IObjectBody > (

generateRandomPosition(width - 20, height - 20)

);




const moveSnake = useCallback(

(dx = 0, dy = 0, ds: string) => {

if (dx > 0 && dy === 0 && ds !== "RIGHT") {

dispatch(makeMove(dx, dy, MOVE_RIGHT));

}




if (dx < 0 && dy === 0 && ds !== "LEFT") {

dispatch(makeMove(dx, dy, MOVE_LEFT));

}




if (dx === 0 && dy < 0 && ds !== "UP") {

dispatch(makeMove(dx, dy, MOVE_UP));

}




if (dx === 0 && dy > 0 && ds !== "DOWN") {

dispatch(makeMove(dx, dy, MOVE_DOWN));

}

},

[dispatch]

);




const handleKeyEvents = useCallback(

(event: KeyboardEvent) => {

if (disallowedDirection) {

switch (event.key) {

case "w":

moveSnake(0, -20, disallowedDirection);

break;

case "s":

moveSnake(0, 20, disallowedDirection);

break;

case "a":

moveSnake(-20, 0, disallowedDirection);

break;

case "d":

event.preventDefault();

moveSnake(20, 0, disallowedDirection);

break;

}

} else {

if (

disallowedDirection !== "LEFT" &&

disallowedDirection !== "UP" &&

disallowedDirection !== "DOWN" &&

event.key === "d"

)

moveSnake(20, 0, disallowedDirection); //Move RIGHT at start

}

},

[disallowedDirection, moveSnake]

);

useEffect(() => {

//Draw on canvas each time

setContext(canvasRef.current && canvasRef.current.getContext("2d")); //store in state variable

clearBoard(context);

drawObject(context, snake1, "#91C483"); //Draws snake at the required position

}, [context]);




useEffect(() => {

window.addEventListener("keypress", handleKeyEvents);




return () => {

window.removeEventListener("keypress", handleKeyEvents);

};

}, [disallowedDirection, handleKeyEvents]);




return (

<canvas

style={{

border: "3px solid black",

}}

height={height}

width={width}

/>

);

};

هنگامی که این تغییرها را انجام دادید، می توانید حرکت مار را امتحان کنید. پس از اجرا خروجی زیر را خواهید دید:

مار متحرک در صفحه

کشیدن میوه در یک موقعیت تصادفی

برای رسم یک میوه در یک موقعیت تصادفی روی صفحه، از تابع generateRandomPosition استفاده می کنیم. بیایید نگاهی به این تابع بیندازیم:

function randomNumber(min: number, max: number) {

let random = Math.random() * max;

return random - (random % 20);

}

export const generateRandomPosition = (width: number, height: number) => {

return {

x: randomNumber(0, width),

y: randomNumber(0, height),

};

};

این تابعی است که مختصات تصادفی x و y را در مضرب 20 تولید می کند. این مختصات همیشه کمتر از عرض و ارتفاع صفحه خواهند بود. عرض و ارتفاع را به عنوان آرگومان می پذیرد.

وقتی این تابع را داشتیم، می‌توانیم از آن برای کشیدن میوه در یک موقعیت تصادفی در داخل صفحه استفاده کنیم.

ابتدا، اجازه دهید یک متغیر حالت pos ایجاد کنیم که در ابتدا از یک موقعیت تصادفی تشکیل شده باشد.

const [pos, setPos] = useState<IObjectBody>(generateRandomPosition(width - 20, height - 20));

سپس، میوه را از طریق تابع drawObject می‌کشیم. پس از این، هوک useEffect خود را به‌روزرسانی می‌کنیم:

useEffect(() => {

//Draw on canvas each time

setContext(canvasRef.current &&   canvasRef.current.getContext("2d")); //store in state variable




clearBoard(context);




drawObject(context, snake1, "#91C483"); //Draws snake at the required position




drawObject(context, [pos], "#676FA3"); //Draws object randomly

}, [context]);

پس از انجام تغییرها صفحه به شکل زیر خواهد بود:

رسم میوه و مار در صفحه

محاسبه کننده امتیاز در بازی مار با React

امتیاز بازی بر اساس تعداد میوه هایی که مار بدون برخورد با خود یا با مرز جعبه خورده است محاسبه می شود. اگر مار میوه را مصرف کند، اندازه مار افزایش می یابد. اگر با لبه صفحه برخورد کند، بازی تمام می شود.

اکنون که می دانیم معیارهای ما برای محاسبه امتیاز چیست، بیایید نگاهی به نحوه محاسبه پاداش بیندازیم.

محاسبه پاداش

پاداش بعد از خوردن میوه توسط مار به صورت زیر محاسبه می شود:

  • اندازه مار را افزایش می یابد.
  • امتیاز را افزایش می یابد.
  • یک میوه جدید در یک مکان تصادفی متفاوت قرار می گیرد.

اگر مار میوه را بخورد، باید اندازه مار را افزایش دهیم. این یک کار بسیار ساده است، ما فقط می توانیم مختصات x و y جدید را اضافه کنیم که کمتر از 20 از آخرین عنصر آرایه state مار هستند. به عنوان مثال، اگر مار مختصات زیر را داشته باشد:

{

snake: [

{ x: 580, y: 300 },

{ x: 560, y: 300 },

{ x: 540, y: 300 },

{ x: 520, y: 300 },

{ x: 500, y: 300 },

],

}

ما باید به سادگی شی زیر را به آرایه snake اضافه کنیم: {x: 480, y: 280}

به این ترتیب اندازه مار را افزایش می دهیم و همچنین قسمت یا بلوک جدید را در انتهای آن اضافه می کنیم. برای این که این کار از طریق Redux و redux-saga پیاده سازی شود، به اکشن و سازنده اکشن زیر نیاز داریم:

export const INCREMENT_SCORE = "INCREMENT_SCORE"; //action




export const increaseSnake = () => ({  //action creator

type: INCREASE_SNAKE

});

هم چنین Reducer را برای تطبیق با این تغییرها به روز خواهیم کرد. case زیر را اضافه می کنیم:

case INCREASE_SNAKE:

const snakeLen = state.snake.length;

return {

...state,

snake: [

...state.snake,

{

x: state.snake[snakeLen - 1].x - 20,

y: state.snake[snakeLen - 1].y - 20,

},

],

};

در کامپوننت CanvasBoard ابتدا یک متغیر حالت به نام isConsumed را معرفی می کنیم. این متغیر بررسی می کند که آیا میوه خورده شده است یا خیر.

const [isConsumed, setIsConsumed] = useState<boolean>(false);

در هوک useEffect خود که در آن مار و میوه خود را درست زیر آن می کشیم، شرط زیر را اضافه می کنیم:

//When the object is consumed

if (snake1[0].x === pos?.x && snake1[0].y === pos?.y) {

setIsConsumed(true);

}

شرط بالا بررسی می کند که آیا سر مار snake[0]  با pos یا موقعیت میوه برابر است یا خیر. اگر true باشد، متغیر isConsumed  با true مقداردهی می شود.

پس از خوردن میوه، باید اندازه مار را افزایش دهیم. ما می توانیم این کار را به راحتی از طریق یک useEffect دیگر انجام دهیم. بیایید useEffect دیگری ایجاد کنیم و increaseSnake را فراخوانی کنیم:

//useEffect2

useEffect(() => {

if (isConsumed) {

//Increase snake size when object is consumed successfully

dispatch(increaseSnake());

}

}, [isConsumed]);

اکنون که اندازه مار را افزایش داده‌ایم، بیایید نگاهی بیندازیم که چگونه می‌توانیم امتیاز را به‌روزرسانی کنیم و یک میوه جدید در موقعیت تصادفی دیگری تولید کنیم.

برای تولید یک میوه جدید در یک موقعیت تصادفی دیگر، متغیر pos  را به روز می کنیم که useEffect1 را دوباره اجرا می کند و شی را در pos می کشد. ما باید useEffect1 خود را با وابستگی جدید pos به روز کنیم و useEffect2 را به صورت زیر به روز کنیم:

useEffect(() => {

//Generate new object

if (isConsumed) {

const posi = generateRandomPosition(width - 20, height - 20);

setPos(posi);

setIsConsumed(false);




//Increase snake size when object is consumed successfully

dispatch(increaseSnake());

}

}, [isConsumed, pos, height, width, dispatch]);

آخرین کاری که باید در این سیستم پاداش انجام دهید این است که هر بار که مار میوه را می خورد، امتیاز را به روز کنید. برای انجام این کار مراحل زیر را دنبال می کنیم:

  1. یک متغیر state سراسری جدید به نام score تعریف می کنیم. state سراسری را مانند زیر در فایل reducers/index.ts به روز می کنیم:
export interface IGlobalState {

snake: ISnakeCoord[] | [];

disallowedDirection: string;

score: number;

}




const globalState: IGlobalState = {

snake: [

{ x: 580, y: 300 },

{ x: 560, y: 300 },

{ x: 540, y: 300 },

{ x: 520, y: 300 },

{ x: 500, y: 300 },

],

disallowedDirection: "",

score: 0,

};
  1. اکشن و سازنده اکشن زیر را در فایل actions/index.ts ایجاد می کنیم:
export const INCREMENT_SCORE = "INCREMENT_SCORE"; //action




//action creator:

export const scoreUpdates = (type: string) => ({

type

});
  1. در مرحله بعد، reducer را برای مدیریت عملکرد INCREMENT_SCORE به‌روزرسانی می کنیم. این به سادگی امتیاز state سراسری را یک بار افزایش می دهد.
case INCREMENT_SCORE:

return {

...state,

score: state.score + 1,

};
  1. سپس score خود را به روز می کنیم و هر بار که مار میوه را می خورد، اکشن INCREMENT_SCORE را می فرستیم. برای این کار می توانیم useEffect2 خود را به صورت زیر به روز کنیم:
useEffect(() => {

//Generate new object

if (isConsumed) {

const posi = generateRandomPosition(width - 20, height - 20);

setPos(posi);

setIsConsumed(false);




//Increase snake size when object is consumed successfully

dispatch(increaseSnake());




//Increment the score

dispatch(scoreUpdates(INCREMENT_SCORE));

}

}, [isConsumed, pos, height, width, dispatch]);
  1. در نهایت یک کامپوننت به نام ScoreCard ایجاد می کنیم. این کامپوننت امتیاز فعلی بازیکن را نمایش می دهد. آن را در فایل components/ScoreCard.tsx ذخیره خواهیم کرد.
import { Heading } from "@chakra-ui/react";

import { useSelector } from "react-redux";

import { IGlobalState } from "../store/reducers";




const ScoreCard = () => {

const score = useSelector((state: IGlobalState) => state.score);

return (

<Heading as="h2" size="md" mt={5} mb={5}>Current Score: {score}</Heading>

);

}




export default ScoreCard;

پس از این، باید کامپوننت ScoreCard را نیز به فایل App.tsx اضافه کنیم تا در صفحه ما نمایش داده شود.

import { ChakraProvider, Container, Heading } from "@chakra-ui/react";

import { Provider } from "react-redux";

import CanvasBoard from "./components/CanvasBoard";

import ScoreCard from "./components/ScoreCard";

import store from "./store";




const App = () => {

return (

<Provider store={store}>

<ChakraProvider>

<Container maxW="container.lg" centerContent>

<Heading as="h1" size="xl">SNAKE GAME</Heading>

<ScoreCard />

<CanvasBoard height={600} width={1000} />

</Container>

</ChakraProvider>

</Provider>

);

};




export default App;

هنگامی که همه چیز در جای خود قرار گرفت، مار ما یک سیستم پاداش کامل خواهد داشت که اندازه مار را برای به روز رسانی امتیاز افزایش می دهد.

بازیکن بازی می کند و طول مار و امتیاز بازیکن افزایش می یابد.

تشخیص برخورد

در این بخش، قصد داریم به نحوه پیاده سازی تشخیص برخورد برای بازی Snake خود نگاهی بیندازیم.

در بازی Snake، اگر برخوردی تشخیص داده شود، بازی تمام می شود یعنی بازی متوقف می شود. دو شرط برای وقوع برخورد وجود دارد:

  1. مار با مرزهای صفحه برخورد کند.
  2. مار با خودش برخورد کند

بیایید نگاهی به شرط اول بیندازیم. فرض کنید سر مار مرزهای صفحه را لمس می کند. در این صورت ما بلافاصله بازی را متوقف خواهیم کرد.

برای این که این اثر در بازی ما گنجانده شود، باید کارهای زیر را انجام دهیم:

  1. یک اکشن و یک سازنده اکشن به صورت زیر ایجاد کنید:
export const STOP_GAME = "STOP_GAME"; //action




//action creator

export const stopGame = () => ({

type: STOP_GAME

});
  1. ما باید فایل sagas/index.ts خود را نیز به روز کنیم. می‌خواهیم مطمئن شویم که Saga پس از رو به رو شدن با اکشن STOP_GAME، فرستادن کنش‌ها را متوقف می‌کند.
export function* moveSaga(params: {
  type: string;
  payload: ISnakeCoord;
}): Generator<
  | PutEffect<{ type: string; payload: ISnakeCoord }>
  | PutEffect<{ type: string; payload: string }>
  | CallEffect<true>
> {
  while (params.type !== STOP_GAME) {
    yield put({
      type: params.type.split("_")[1],
      payload: params.payload,
    });
    switch (params.type.split("_")[1]) {
      case RIGHT:
        yield put(setDisDirection(LEFT));
        break;

      case LEFT:
        yield put(setDisDirection(RIGHT));
        break;

      case UP:
        yield put(setDisDirection(DOWN));
        break;

      case DOWN:
        yield put(setDisDirection(UP));
        break;
    }
    yield delay(100);
  }
}

function* watcherSagas() {
  yield takeLatest(
    [MOVE_RIGHT, MOVE_LEFT, MOVE_UP, MOVE_DOWN, STOP_GAME],
    moveSaga
  );
}
  1. در نهایت باید useEffect1 را با افزودن شرط زیر به روز کنیم:
if ( //Checks if the snake head is out of the boundries of the obox
      snake1[0].x >= width ||
      snake1[0].x <= 0 ||
      snake1[0].y <= 0 ||
      snake1[0].y >= height
    ) {
      setGameEnded(true);
      dispatch(stopGame());
      window.removeEventListener("keypress", handleKeyEvents);
    }

هم چنین شنونده رویداد handleKeyEvents را حذف می کنیم. این اطمینان حاصل می کند که پس از پایان بازی، بازیکن نمی تواند مار را حرکت دهد.

در نهایت، بیایید نگاهی به چگونگی تشخیص برخورد خود مار بیندازیم. ما قصد داریم از یک تابع ابزار به نام hasSnakeCollided استفاده کنیم. دو پارامتر را می پذیرد: اولی آرایه snake و دومی سر مار است. اگر سر مار به قسمت‌هایی از خودش برخورد کند، true یا false برمی‌گردد.تابع hasSnakeCollided به شکل زیر خواهد بود:

export const hasSnakeCollided = (
  snake: IObjectBody[],
  currentHeadPos: IObjectBody
) => {
  let flag = false;
  snake.forEach((pos: IObjectBody, index: number) => {
    if (
      pos.x === currentHeadPos.x &&
      pos.y === currentHeadPos.y &&
      index !== 0
    ) {
      flag = true;
    }
  });

  return flag;
};

ممکن است نیاز داشته باشیم که useEffect1 را با به‌روزرسانی شرایط تشخیص برخورد مانند زیر به‌روزرسانی کنیم:

if (  
      //Checks if the snake has collided with itself 
      hasSnakeCollided(snake1, snake1[0]) ||
      
      //Checks if the snake head is out of the boundries of the obox
      snake1[0].x >= width ||
      snake1[0].x <= 0 ||
      snake1[0].y <= 0 ||
      snake1[0].y >= height
    ) {
      setGameEnded(true);
      dispatch(stopGame());
      window.removeEventListener("keypress", handleKeyEvents);
    }

هنگامی که سیستم تشخیص برخورد را اضافه کنیم، بازی ما مانند زیر خواهد بود:

تشخیص برخورد

کامپوننت Instruction

در حال حاضر به پایان بازی نزدیک شده ایم. کامپوننت نهایی ما Instruction  خواهد بود. این شامل دستورالعمل هایی در مورد بازی مانند شرایط اولیه بازی، کلیدهای use و دکمه reset  است.

بیایید با ایجاد فایلی به نام components/Instructions.tsx شروع کنیم. کد زیر را در این فایل قرار دهید:

import { Box, Button, Flex, Heading, Kbd } from "@chakra-ui/react";

export interface IInstructionProps {
  resetBoard: () => void;
}
const Instruction = ({ resetBoard }: IInstructionProps) => (
  <Box mt={3}>
    <Heading as="h6" size="lg">
      How to Play
    </Heading>
    <Heading as="h5" size="sm" mt={1}>
    NOTE: Start the game by pressing <Kbd>d</Kbd>
    </Heading>
    <Flex flexDirection="row" mt={3}>
      <Flex flexDirection={"column"}>
        <span>
          <Kbd>w</Kbd> Move Up
        </span>
        <span>
          <Kbd>a</Kbd> Move Left
        </span>
        <span>
          <Kbd>s</Kbd> Move Down
        </span>
        <span>
          <Kbd>d</Kbd> Move Right
        </span>
      </Flex>
      <Flex flexDirection="column">
        <Button onClick={() => resetBoard()}>Reset game</Button>
      </Flex>
    </Flex>
  </Box>
);

export default Instruction;

کامپوننت Instruction  متغیر resetBoard را به عنوان یک prop می پذیرد و تابعی است که به کاربر در زمانی که بازی تمام می شود یا زمانی که می خواهد بازی را reset کند کمک می کند.

پیش از این که وارد تابع resetBoard شویم، باید به‌روزرسانی‌های زیر را در store Redux و saga خود انجام دهیم:

  1. اکشن و سازنده اکشن زیر را در فایل actions/index.ts اضافه می کنیم:
export const RESET_SCORE = "RESET_SCORE"; //action
export const RESET = "RESET"; //action

//Action creator:
export const resetGame = () => ({
  type: RESET
});
  1. سپس شرط زیر را به sagas/index.ts اضافه می کنیم. می‌خواهیم مطمئن شویم که saga پس از مواجهه با اکشن های RESET و STOP_GAME، فرستادن اکشن ها را متوقف می‌کند.
export function* moveSaga(params: {
  type: string;
  payload: ISnakeCoord;
}): Generator<
  | PutEffect<{ type: string; payload: ISnakeCoord }>
  | PutEffect<{ type: string; payload: string }>
  | CallEffect<true>
> {
  while (params.type !== RESET && params.type !== STOP_GAME) {
    yield put({
      type: params.type.split("_")[1],
      payload: params.payload,
    });
    switch (params.type.split("_")[1]) {
      case RIGHT:
        yield put(setDisDirection(LEFT));
        break;

      case LEFT:
        yield put(setDisDirection(RIGHT));
        break;

      case UP:
        yield put(setDisDirection(DOWN));
        break;

      case DOWN:
        yield put(setDisDirection(UP));
        break;
    }
    yield delay(100);
  }
}

function* watcherSagas() {
  yield takeLatest(
    [MOVE_RIGHT, MOVE_LEFT, MOVE_UP, MOVE_DOWN, RESET, STOP_GAME],
    moveSaga
  );
}
  1. در نهایت، فایل Reducers/index.ts خود را برای مورد RESET_SCORE به صورت زیر به روز می کنیم:
case RESET_SCORE:      return { ...state, score: 0 };

هنگامی که sage ها و reducer های ما به روز می شوند، می توانیم نگاهی به عملیاتی که ResetBoard  انجام می دهد بیندازیم.

تابع resetBoard عملیات زیر را انجام می دهد:

  1. شنونده رویداد handleKeyEvents را حذف می کند
  2. اکشن های لازم برای تنظیم مجدد بازی را ارسال می کند.
  3. اقدام را برای بازنشانی امتیاز ارسال می کند.
  4. canvas را پاک می کند.
  5. مار را دوباره در موقعیت اولیه خود می کشد
  6. میوه را در یک موقعیت تصادفی جدید ترسیم می کند.
  7. در نهایت، شنونده رویداد handleKeyEvents را برای رویداد فشار کلید اضافه می کند.

در زیر نحوه تابع resetBoard نشان داده شده است:

const resetBoard = useCallback(() => {
    window.removeEventListener("keypress", handleKeyEvents);
    dispatch(resetGame());
    dispatch(scoreUpdates(RESET_SCORE));
    clearBoard(context);
    drawObject(context, snake1, "#91C483");
    drawObject(
      context,
      [generateRandomPosition(width - 20, height - 20)],
      "#676FA3"
    ); //Draws object randomly
    window.addEventListener("keypress", handleKeyEvents);
  }, [context, dispatch, handleKeyEvents, height, snake1, width]);

شما باید این تابع را در داخل کامپوننت CanvasBoard قرار دهید و تابع resetBoard را به‌عنوان prop به تابع Instruction به صورت زیر بفرستید:

<>
      <canvas
        ref={canvasRef}
        style={{
          border: `3px solid ${gameEnded ? "red" : "black"}`,
        }}
        width={width}
        height={height}
      />
      <Instruction resetBoard={resetBoard} />
    </>

پس از قرار دادن این، کامپوننت Instruction را مانند زیر تنظیم خواهیم کرد:

دستورالعمل ها با دکمه reset

بازی نهایی

اگر تا این مرحله دنبال کرده اید، تبریک می گوییم! شما با موفقیت یک بازی سرگرم کننده Snake را با React ،Redux و redux-sagas ایجاد کرده اید. پس از اتصال همه این موارد، بازی شما به شکل زیر خواهد بود:

کد کامل بازی در این نشانی آمده است.


منبع: وب سایت freecodecamp

نویسنده شوید
دیدگاه‌های شما

در این قسمت، به پرسش‌های تخصصی شما درباره‌ی محتوای مقاله پاسخ داده نمی‌شود. سوالات خود را اینجا بپرسید.