From ab6292a0ce91ace230faa7b4238b6a4a7d31c813 Mon Sep 17 00:00:00 2001 From: Daniil <77277774+shamemask@users.noreply.github.com> Date: Thu, 26 Sep 2024 21:14:58 +0300 Subject: [PATCH] [SOK-27] +enemys +obstacles +start +pause --- packages/client/src/app/App.tsx | 4 +- .../src/{pages => components}/Game/Game.scss | 47 +++++- packages/client/src/components/Game/Game.tsx | 133 ++++++++++++++++ .../client/src/components/Game/collision.tsx | 0 .../client/src/components/Game/controls.tsx | 46 ++++++ packages/client/src/components/Game/enemy.tsx | 61 +++++++ .../client/src/components/Game/gameLoop.tsx | 90 +++++++++++ .../client/src/components/Game/gameTypes.tsx | 28 ++++ .../client/src/components/Game/obstacle.tsx | 12 ++ .../client/src/components/Game/player.tsx | 80 ++++++++++ packages/client/src/components/Game/utils.tsx | 52 ++++++ packages/client/src/pages/Game/Game.tsx | 150 ++---------------- packages/client/src/pages/Game/GamePage.scss | 7 + 13 files changed, 565 insertions(+), 145 deletions(-) rename packages/client/src/{pages => components}/Game/Game.scss (50%) create mode 100644 packages/client/src/components/Game/Game.tsx create mode 100644 packages/client/src/components/Game/collision.tsx create mode 100644 packages/client/src/components/Game/controls.tsx create mode 100644 packages/client/src/components/Game/enemy.tsx create mode 100644 packages/client/src/components/Game/gameLoop.tsx create mode 100644 packages/client/src/components/Game/gameTypes.tsx create mode 100644 packages/client/src/components/Game/obstacle.tsx create mode 100644 packages/client/src/components/Game/player.tsx create mode 100644 packages/client/src/components/Game/utils.tsx create mode 100644 packages/client/src/pages/Game/GamePage.scss diff --git a/packages/client/src/app/App.tsx b/packages/client/src/app/App.tsx index b960df3..e5d3966 100644 --- a/packages/client/src/app/App.tsx +++ b/packages/client/src/app/App.tsx @@ -7,7 +7,7 @@ import PublicLayout from '@/layouts/public-layout' import RootLayout from '@/layouts/root-layout' import { Error } from '@/pages/Error/Error' import { Forum } from '@/pages/Forum/Forum' -import { Game } from '@/pages/Game/Game' +import { GamePage } from '@/pages/Game/Game' import { Leaderboard } from '@/pages/Leaderboard/Leaderboard' import { Main } from '@/pages/Main/Main' import { ChangePassword } from '@/pages/Profile/ChangePassword' @@ -40,7 +40,7 @@ const routerConfig = createBrowserRouter([ children: [ { path: '/game', - element: , + element: , }, { path: '/forum', diff --git a/packages/client/src/pages/Game/Game.scss b/packages/client/src/components/Game/Game.scss similarity index 50% rename from packages/client/src/pages/Game/Game.scss rename to packages/client/src/components/Game/Game.scss index a1842d2..b640093 100644 --- a/packages/client/src/pages/Game/Game.scss +++ b/packages/client/src/components/Game/Game.scss @@ -3,14 +3,39 @@ .game-container { display: flex; justify-content: center; - align-items: center; - width: 100vw; - height: 100vh; + flex-direction: column; + align-items: stretch; background-color: $c_default-background; font-family: $f_default-font-family; overflow: hidden; + position: relative; } +.modal { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: rgba(0, 0, 0, 0.8); + color: white; + padding: 20px; + border-radius: 8px; + text-align: center; +} + +button { + margin-top: 10px; + padding: 10px 20px; + border: none; + background-color: #839d22; + color: white; + border-radius: 8px; + cursor: pointer; +} + +button:hover { + background-color: #53650b; +} canvas { border-radius: $border-radius--default; box-shadow: 0 4px 8px rgba($c_black, 0.5); @@ -40,3 +65,19 @@ canvas { top: 10px; left: 10px; } + +.game-info { + position: absolute; + top: 20px; + left: 20px; + color: $c_font-default; + font-size: 18px; + background: rgba($c_black, 0.5); + padding: 8px 12px; + border-radius: 6px; +} + +.lives { + color: $c_button-top; + font-weight: bold; +} diff --git a/packages/client/src/components/Game/Game.tsx b/packages/client/src/components/Game/Game.tsx new file mode 100644 index 0000000..c664303 --- /dev/null +++ b/packages/client/src/components/Game/Game.tsx @@ -0,0 +1,133 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import './Game.scss' +import { initializeEnemies } from '@/components/Game/enemy' +import { initializePlayer } from '@/components/Game/player' +import { gameLoop } from '@/components/Game/gameLoop' +import { + handleKeyDown, + handleKeyUp, + updatePlayerMovement, +} from '@/components/Game/controls' +import { Obstacle } from '@/components/Game/gameTypes' +import { initializeObstacle } from '@/components/Game/obstacle' + +const speedFactor = 0.3 +const livesUse = 3 + +export const Game: React.FC = () => { + const canvasRef = useRef(null) + let context: CanvasRenderingContext2D | null | undefined + const [lives, setLives] = useState(livesUse) + const [player, setPlayer] = useState(initializePlayer()) + const [enemies, setEnemies] = useState(initializeEnemies()) + const [obstacles, setObstacles] = useState(initializeObstacle()) + const [isPaused, setIsPaused] = useState(false) + const [isGameOver, setIsGameOver] = useState(false) + const [gameStarted, setGameStarted] = useState(false) + const [delay, setDelay] = useState(0) + const [lastTimestamp, setLastTimestamp] = useState(0) + + const togglePause = () => { + setIsPaused(prev => !prev) + } + + const handleGameOver = useCallback(() => { + setIsGameOver(true) + setIsPaused(true) + }, []) + + const handleContinue = () => { + setIsPaused(false) + if (isGameOver) { + setIsGameOver(false) + setLives(livesUse) + setGameStarted(false) + } + } + + const loop = useCallback( + (timestamp: number) => { + if (!isPaused && !isGameOver && context) { + if (lastTimestamp) { + const elapsed = timestamp - lastTimestamp + setDelay(elapsed) + } + updatePlayerMovement(player, setPlayer, speedFactor) + gameLoop( + timestamp, + context, + player, + setPlayer, + enemies, + setEnemies, + obstacles, + lives, + setLives, + speedFactor, + handleGameOver, + isPaused, + isGameOver + ) + requestAnimationFrame(loop) + } + }, + [ + isPaused, + isGameOver, + lastTimestamp, + player, + enemies, + lives, + handleGameOver, + ] + ) + + useEffect(() => { + window.addEventListener('keydown', handleKeyDown) + window.addEventListener('keyup', handleKeyUp) + + return () => { + window.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('keyup', handleKeyUp) + } + }, []) + + useEffect(() => { + if (gameStarted && !isPaused) { + const canvas = canvasRef.current + context = canvas?.getContext('2d') + + if (context) { + requestAnimationFrame(loop) + } + } + }, [isPaused, isGameOver, loop]) + + const startGame = () => { + setGameStarted(true) + handleContinue() + } + + return ( +
+
Жизни: {lives}
+ + + {!gameStarted ? ( + + ) : ( + + )} + + {/* Модальное окно при проигрыше */} + {isGameOver && ( +
+

Игра окончена

+ +
+ )} +
+ ) +} diff --git a/packages/client/src/components/Game/collision.tsx b/packages/client/src/components/Game/collision.tsx new file mode 100644 index 0000000..e69de29 diff --git a/packages/client/src/components/Game/controls.tsx b/packages/client/src/components/Game/controls.tsx new file mode 100644 index 0000000..6aa62c4 --- /dev/null +++ b/packages/client/src/components/Game/controls.tsx @@ -0,0 +1,46 @@ +import { KeyMap, Player } from '@/components/Game/gameTypes' + +const keyMap: KeyMap = {} + +// Обработчик нажатия клавиш +export const handleKeyDown = (event: KeyboardEvent) => { + keyMap[event.key] = true +} + +// Обработчик отпускания клавиш +export const handleKeyUp = (event: KeyboardEvent) => { + keyMap[event.key] = false +} + +// Функция для обновления позиции игрока на основе нажатых клавиш +export const updatePlayerMovement = ( + player: Player, + setPlayer: React.Dispatch>, + speedFactor: number +) => { + const speed = player.speed * speedFactor + + setPlayer(prevPlayer => { + let newX = prevPlayer.x + let newY = prevPlayer.y + + if (keyMap['ArrowUp'] || keyMap['w'] || keyMap['ц']) { + newY -= speed + } + if (keyMap['ArrowDown'] || keyMap['s'] || keyMap['ы']) { + newY += speed + } + if (keyMap['ArrowLeft'] || keyMap['a'] || keyMap['ф']) { + newX -= speed + } + if (keyMap['ArrowRight'] || keyMap['d'] || keyMap['в']) { + newX += speed + } + + // Ограничение движения по краям экрана + newX = Math.max(0, Math.min(newX, window.innerWidth - prevPlayer.width)) + newY = Math.max(0, Math.min(newY, window.innerHeight - prevPlayer.height)) + + return { ...prevPlayer, x: newX, y: newY } + }) +} diff --git a/packages/client/src/components/Game/enemy.tsx b/packages/client/src/components/Game/enemy.tsx new file mode 100644 index 0000000..4f8aaff --- /dev/null +++ b/packages/client/src/components/Game/enemy.tsx @@ -0,0 +1,61 @@ +import { getRandomEdgePosition } from './utils' +import { Enemy, Obstacle, Player } from '@/components/Game/gameTypes' + +export const initializeEnemies = (): Enemy[] => { + const initialEnemies: Enemy[] = [] + for (let i = 0; i < 5; i++) { + // количество врагов + const { x, y } = getRandomEdgePosition(800, 600) + const enemy: Enemy = { + x, + y, + width: 30, + height: 30, + speed: 2, + direction: { x: 0, y: 0 }, + } + initialEnemies.push(enemy) + } + return initialEnemies +} + +export const updateEnemyPositions = ( + player: Player, + enemies: Enemy[], + setEnemies: React.Dispatch>, + speedFactor: number +) => { + setEnemies(prevEnemies => + prevEnemies.map(enemy => { + const directionX = player.x - enemy.x + const directionY = player.y - enemy.y + + const magnitude = Math.sqrt(directionX ** 5 + directionY ** 5) + const normalizedX = directionX / magnitude + const normalizedY = directionY / magnitude + + const newX = enemy.x + normalizedX * enemy.speed * speedFactor + const newY = enemy.y + normalizedY * enemy.speed * speedFactor + + return { ...enemy, x: newX, y: newY } + }) + ) +} + +export const detectEnemyCollision = ( + rect1: Player | Enemy, + rect2: Obstacle | Enemy +): boolean => { + return ( + rect1.x < rect2.x + rect2.width && + rect1.x + rect1.width > rect2.x && + rect1.y < rect2.y + rect2.height && + rect1.y + rect1.height > rect2.y + ) +} + +export const respawnEnemies = ( + setEnemies: React.Dispatch> +) => { + setEnemies(initializeEnemies()) +} diff --git a/packages/client/src/components/Game/gameLoop.tsx b/packages/client/src/components/Game/gameLoop.tsx new file mode 100644 index 0000000..72af25d --- /dev/null +++ b/packages/client/src/components/Game/gameLoop.tsx @@ -0,0 +1,90 @@ +import { + handlePlayerHit, + resetPlayerPosition, + updatePlayerPosition, +} from './player' +import { + updateEnemyPositions, + respawnEnemies, + detectEnemyCollision, +} from './enemy' +import { clearCanvas, drawPlayer, drawEnemies, drawObstacles } from './utils' +import { Enemy, Obstacle, Player } from '@/components/Game/gameTypes' + +/** + * Основной игровой цикл, который обновляет состояние игры и перерисовывает экран каждый кадр. + * @param timestamp - Время, прошедшее с начала игры, используется для расчета обновлений. + * @param context - Контекст рисования для Canvas. + * @param player - Объект игрока. + * @param setPlayer - Функция для обновления состояния игрока. + * @param enemies - Массив врагов. + * @param setEnemies - Функция для обновления состояния врагов. + * @param obstacles - Массив препятствий. + * @param lives - Текущее количество жизней игрока. + * @param setLives - Функция для изменения количества жизней игрока. + * @param speedFactor - Коэффициент скорости. + */ +export const gameLoop = ( + timestamp: number, + context: CanvasRenderingContext2D, + player: Player, + setPlayer: React.Dispatch>, + enemies: Enemy[], + setEnemies: React.Dispatch>, + obstacles: Obstacle[], + lives: number, + setLives: React.Dispatch>, + speedFactor: number, + handleGameOver: () => void, + isPaused: boolean, + isGameOver: boolean +) => { + clearCanvas(context) + + // Обновление позиций врагов + updateEnemyPositions(player, enemies, setEnemies, speedFactor) + // Отрисовка всех игровых объектов + drawObstacles(context, obstacles) + drawPlayer(context, player) + drawEnemies(context, enemies) + + // Проверка на столкновения между игроком и врагами + enemies.forEach(enemy => { + if (detectEnemyCollision(player, enemy)) { + // Обработка столкновения: уменьшаем жизни + handlePlayerHit( + setPlayer, + setLives, + () => resetPlayerPosition(setPlayer), + respawnEnemies, + setEnemies + ) + + // Проверка на конец игры + if (lives - 1 <= 0) { + handleGameOver() // Вызываем окончание игры, если жизни закончились + } + } + }) + + // Запуск следующего кадра (будет работать только если игра не на паузе) + if (!isPaused && !isGameOver) { + requestAnimationFrame(newTimestamp => + gameLoop( + newTimestamp, + context, + player, + setPlayer, + enemies, + setEnemies, + obstacles, + lives, + setLives, + speedFactor, + handleGameOver, + isPaused, + isGameOver + ) + ) + } +} diff --git a/packages/client/src/components/Game/gameTypes.tsx b/packages/client/src/components/Game/gameTypes.tsx new file mode 100644 index 0000000..619df42 --- /dev/null +++ b/packages/client/src/components/Game/gameTypes.tsx @@ -0,0 +1,28 @@ +export interface Player { + x: number + y: number + width: number + height: number + speed: number + direction: { x: number; y: number } +} + +export interface Enemy { + x: number + y: number + width: number + height: number + speed: number + direction: { x: number; y: number } +} + +export interface Obstacle { + x: number + y: number + width: number + height: number +} + +export interface KeyMap { + [key: string]: boolean +} diff --git a/packages/client/src/components/Game/obstacle.tsx b/packages/client/src/components/Game/obstacle.tsx new file mode 100644 index 0000000..3743838 --- /dev/null +++ b/packages/client/src/components/Game/obstacle.tsx @@ -0,0 +1,12 @@ +import { Obstacle } from '@/components/Game/gameTypes' +import { getRandomEdgePosition } from '@/components/Game/utils' + +export const initializeObstacle = (): Obstacle[] => { + const obstacles: Obstacle[] = [] + for (let i = 0; i < 10; i++) { + const { x, y } = getRandomEdgePosition(800, 600) + const obstacle: Obstacle = { x, y, width: 50, height: 50 } + obstacles.push(obstacle) + } + return obstacles +} diff --git a/packages/client/src/components/Game/player.tsx b/packages/client/src/components/Game/player.tsx new file mode 100644 index 0000000..b67c2dc --- /dev/null +++ b/packages/client/src/components/Game/player.tsx @@ -0,0 +1,80 @@ +import { respawnEnemies } from '@/components/Game/enemy' +import { Enemy, Obstacle, Player } from '@/components/Game/gameTypes' + +export const initializePlayer = () => ({ + x: 400, + y: 300, + width: 30, + height: 30, + speed: 0.1, + direction: { x: 0, y: 0 }, +}) + +const detectCollision = (player: Player, obstacle: Obstacle): boolean => { + return ( + player.x < obstacle.x + obstacle.width && + player.x + player.width > obstacle.x && + player.y < obstacle.y + obstacle.height && + player.y + player.height > obstacle.y + ) +} + +export const updatePlayerPosition = ( + player: Player, + setPlayer: React.Dispatch>, + obstacles: Obstacle[] +) => { + const newX = player.x + player.direction.x * player.speed + const newY = player.y + player.direction.y * player.speed + + let collisionDetected = false + obstacles.forEach(obstacle => { + if (detectCollision({ ...player, x: newX, y: newY }, obstacle)) { + collisionDetected = true + } + }) + if (!collisionDetected) { + setPlayer(prev => ({ + ...prev, + x: newX, + y: newY, + })) + } +} + +export const resetPlayerPosition = ( + setPlayer: React.Dispatch> +) => { + setPlayer(initializePlayer()) +} + +/** + * Функция для обработки столкновения игрока с врагом. + * @param setLives - Функция для изменения количества жизней игрока. + * @param resetPlayerPosition - Функция для сброса позиции игрока. + * @param respawnEnemies - Функция для респауна врагов. + * @param setEnemies - Функция для обновления состояния врагов. + */ +export const handlePlayerHit = ( + setPlayer: React.Dispatch>, + setLives: React.Dispatch>, + resetPlayerPosition: ( + setPlayer: React.Dispatch> + ) => void, + respawnEnemies: ( + setEnemies: React.Dispatch> + ) => void, + setEnemies: React.Dispatch> +) => { + setLives(prevLives => { + const newLives = prevLives - 1 + if (newLives <= 0) { + console.log('Game over!') + window.location.reload() + } else { + resetPlayerPosition(setPlayer) + respawnEnemies(setEnemies) + } + return newLives + }) +} diff --git a/packages/client/src/components/Game/utils.tsx b/packages/client/src/components/Game/utils.tsx new file mode 100644 index 0000000..139dc35 --- /dev/null +++ b/packages/client/src/components/Game/utils.tsx @@ -0,0 +1,52 @@ +import { Enemy, Obstacle, Player } from '@/components/Game/gameTypes' + +export const getRandomEdgePosition = ( + canvasWidth: number, + canvasHeight: number +): { x: number; y: number } => { + const edge = Math.floor(Math.random() * 4) + switch (edge) { + case 0: + return { x: Math.random() * canvasWidth, y: 0 } + case 1: + return { x: canvasWidth, y: Math.random() * canvasHeight } + case 2: + return { x: Math.random() * canvasWidth, y: canvasHeight } + case 3: + return { x: 0, y: Math.random() * canvasHeight } + default: + return { x: 0, y: 0 } + } +} + +export const clearCanvas = (context: CanvasRenderingContext2D) => { + context.clearRect(0, 0, context.canvas.width, context.canvas.height) +} + +export const drawPlayer = ( + context: CanvasRenderingContext2D, + player: Player +) => { + context.fillStyle = 'gray' + context.fillRect(player.x, player.y, player.width, player.height) +} + +export const drawEnemies = ( + context: CanvasRenderingContext2D, + enemies: Enemy[] +) => { + context.fillStyle = 'red' + enemies.forEach(enemy => { + context.fillRect(enemy.x, enemy.y, enemy.width, enemy.height) + }) +} + +export const drawObstacles = ( + context: CanvasRenderingContext2D, + obstacles: Obstacle[] +) => { + context.fillStyle = 'black' + obstacles.forEach(obstacle => { + context.fillRect(obstacle.x, obstacle.y, obstacle.width, obstacle.height) + }) +} diff --git a/packages/client/src/pages/Game/Game.tsx b/packages/client/src/pages/Game/Game.tsx index 5b4a644..78c85ef 100644 --- a/packages/client/src/pages/Game/Game.tsx +++ b/packages/client/src/pages/Game/Game.tsx @@ -1,141 +1,11 @@ -import React, { useEffect, useRef, useState } from 'react' -import './Game.scss' - -interface Player { - x: number - y: number - width: number - height: number - speed: number - direction: { x: number; y: number } -} - -interface Obstacle { - x: number - y: number - width: number - height: number -} - -export const Game: React.FC = () => { - const canvasRef = useRef(null) - const [player, setPlayer] = useState({ - x: 50, - y: 50, - width: 30, - height: 30, - speed: 5, - direction: { x: 0, y: 0 }, - }) - - const obstacles: Obstacle[] = [ - { x: 200, y: 200, width: 50, height: 50 }, - { x: 400, y: 100, width: 50, height: 50 }, - ] - - useEffect(() => { - const canvas = canvasRef.current - if (!canvas) return - - const context = canvas.getContext('2d') - if (!context) return - - const handleKeyDown = (e: KeyboardEvent) => { - switch (e.key) { - case 'ArrowUp': - setPlayer(prev => ({ ...prev, direction: { x: 0, y: -1 } })) - break - case 'ArrowDown': - setPlayer(prev => ({ ...prev, direction: { x: 0, y: 1 } })) - break - case 'ArrowLeft': - setPlayer(prev => ({ ...prev, direction: { x: -1, y: 0 } })) - break - case 'ArrowRight': - setPlayer(prev => ({ ...prev, direction: { x: 1, y: 0 } })) - break - default: - break - } - } - - const handleKeyUp = () => { - setPlayer(prev => ({ ...prev, direction: { x: 0, y: 0 } })) - } - - const detectCollision = (player: Player, obstacle: Obstacle): boolean => { - return ( - player.x < obstacle.x + obstacle.width && - player.x + player.width > obstacle.x && - player.y < obstacle.y + obstacle.height && - player.y + player.height > obstacle.y - ) - } - - const gameLoop = (timestamp: number) => { - const deltaTime = timestamp - lastTime - if (deltaTime > 1000 / 60) { - updatePlayerPosition() - clearCanvas() - drawPlayer() - drawObstacles(context) - lastTime = timestamp - } - requestAnimationFrame(gameLoop) - } - - let lastTime = 0 - const updatePlayerPosition = () => { - const newX = player.x + player.direction.x * player.speed - const newY = player.y + player.direction.y * player.speed - - let collisionDetected = false - obstacles.forEach(obstacle => { - if (detectCollision({ ...player, x: newX, y: newY }, obstacle)) { - collisionDetected = true - } - }) - - if (!collisionDetected) { - setPlayer(prev => ({ - ...prev, - x: newX, - y: newY, - })) - } - } - - const clearCanvas = () => { - context.clearRect(0, 0, canvas.width, canvas.height) - } - - const drawPlayer = () => { - context.fillStyle = 'gray' - context.fillRect(player.x, player.y, player.width, player.height) - } - - const drawObstacles = (context: CanvasRenderingContext2D) => { - context.fillStyle = 'black' - obstacles.forEach(obstacle => { - context.fillRect( - obstacle.x, - obstacle.y, - obstacle.width, - obstacle.height - ) - }) - } - - window.addEventListener('keydown', handleKeyDown) - window.addEventListener('keyup', handleKeyUp) - - gameLoop(0) - - return () => { - window.removeEventListener('keydown', handleKeyDown) - window.removeEventListener('keyup', handleKeyUp) - } - }, [player, obstacles]) - - return +import { Game } from '@/components/Game/Game' +import './GamePage.scss' + +export const GamePage: React.FC = () => { + return ( +
+

Falcon Tanks

+ +
+ ) } diff --git a/packages/client/src/pages/Game/GamePage.scss b/packages/client/src/pages/Game/GamePage.scss new file mode 100644 index 0000000..8ba7e90 --- /dev/null +++ b/packages/client/src/pages/Game/GamePage.scss @@ -0,0 +1,7 @@ +.game-page { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + background-color: #010101; // Цвет фона для всей страницы +}