diff --git a/packages/client/src/components/Game/Game.scss b/packages/client/src/components/Game/Game.scss new file mode 100644 index 0000000..93e80be --- /dev/null +++ b/packages/client/src/components/Game/Game.scss @@ -0,0 +1,58 @@ +@import '../../scss/vars'; + +.game-container { + display: flex; + justify-content: center; + flex-direction: column; + align-items: stretch; + background-color: $c_default-background; + font-family: $f_default-font-family; + overflow: hidden; + position: relative; +} + +canvas { + border-radius: $border-radius--default; + box-shadow: 0 4px 8px rgba($c_black, 0.5); + background-image: $img_default-background; + background-size: cover; +} + +.player { + position: absolute; + background-color: $c_button; + border: 2px solid $c_button-top; + border-radius: 4px; + transition: transform 0.1s ease; +} + +.obstacle { + position: absolute; + background-color: $c_button-bottom; + border: 2px solid $c_black; + border-radius: 4px; +} + +.game-text { + color: $c_font-default; + font-size: 16px; + position: absolute; + 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..46cc8c7 --- /dev/null +++ b/packages/client/src/components/Game/Game.tsx @@ -0,0 +1,113 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import './Game.scss' +import { initializeEnemies } from '@/components/Game/enemy' +import { PLAYER_DEFAULT_PARAMS } from '@/components/Game/player' +import { gameLoop } from '@/components/Game/gameLoop' +import { + handleKeyDown, + handleKeyUp, + updatePlayerMovement, +} from '@/components/Game/controls' +import { ControlsProps, Obstacle } from '@/components/Game/gameTypes' +import { initializeObstacle } from '@/components/Game/obstacle' +import { Modal } from '../common/Modal/Modal' + +const livesUse = 3 + +export const Game: React.FC = () => { + const canvasRef = useRef(null) + const playerRef = useRef(PLAYER_DEFAULT_PARAMS) + const enemiesRef = useRef(initializeEnemies(5)) + const obstaclesRef = useRef(initializeObstacle()) + const livesRef = useRef(livesUse) + const [gameStarted, setGameStarted] = useState(false) + const [isPaused, setIsPaused] = useState(false) + const isPausedRef = useRef(false) + const [isGameOver, setIsGameOver] = useState(false) + + const togglePause = useCallback(() => { + setIsPaused(prev => !prev) + isPausedRef.current = !isPausedRef.current // Обновляем ref для паузы + }, []) + + const handleGameOver = useCallback(() => { + setIsGameOver(true) + setIsPaused(true) + isPausedRef.current = true + }, []) + + const loop = useCallback(() => { + if (!isPausedRef.current && !isGameOver && canvasRef.current) { + const context = canvasRef.current.getContext('2d') + if (context) { + const moveProps: ControlsProps = { + playerRef, + obstacles: obstaclesRef.current, + canvasWidth: canvasRef.current.width, + canvasHeight: canvasRef.current.height, + } + updatePlayerMovement(moveProps) + + gameLoop( + context, + playerRef, + enemiesRef, + obstaclesRef.current, + livesRef, + handleGameOver + ) + } + requestAnimationFrame(loop) + } + }, [isGameOver, handleGameOver, livesRef]) + + useEffect(() => { + const handleKeyDownWrapper = (event: KeyboardEvent) => + handleKeyDown(event.key) + const handleKeyUpWrapper = (event: KeyboardEvent) => handleKeyUp(event.key) + + window.addEventListener('keydown', handleKeyDownWrapper) + window.addEventListener('keyup', handleKeyUpWrapper) + + return () => { + window.removeEventListener('keydown', handleKeyDownWrapper) + window.removeEventListener('keyup', handleKeyUpWrapper) + } + }, []) + + useEffect(() => { + if (gameStarted && !isPaused) { + requestAnimationFrame(loop) + } + }, [gameStarted, isPaused, loop]) + + const startGame = () => { + setGameStarted(true) + setIsPaused(false) + isPausedRef.current = false + setIsGameOver(false) + livesRef.current = livesUse + playerRef.current = PLAYER_DEFAULT_PARAMS + enemiesRef.current = initializeEnemies(5) + } + + return ( +
+
{`Жизни: ${livesRef.current.toString()}`}
+ + + {!gameStarted ? ( + + ) : ( + + )} + + setIsGameOver(false)}> +

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

+ +
+
+ ) +} diff --git a/packages/client/src/components/Game/collision.tsx b/packages/client/src/components/Game/collision.tsx new file mode 100644 index 0000000..6eca1ae --- /dev/null +++ b/packages/client/src/components/Game/collision.tsx @@ -0,0 +1,25 @@ +import { Enemy, Obstacle, Player } from '@/components/Game/gameTypes' + +export 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 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 + ) +} diff --git a/packages/client/src/components/Game/controls.tsx b/packages/client/src/components/Game/controls.tsx new file mode 100644 index 0000000..509690a --- /dev/null +++ b/packages/client/src/components/Game/controls.tsx @@ -0,0 +1,61 @@ +import { ControlsProps, KeyMap } from '@/components/Game/gameTypes' + +import { detectCollision } from '@/components/Game/collision' + +const keyMap: KeyMap = {} + +// Обработчик нажатия клавиш +export const handleKeyDown = (key: string) => { + keyMap[key] = true +} + +// Обработчик отпускания клавиш +export const handleKeyUp = (key: string) => { + delete keyMap[key] +} + +// Функция для обновления позиции игрока на основе нажатых клавиш +export const updatePlayerMovement = (props: ControlsProps) => { + const speed = props.playerRef.current.speed + let newX = props.playerRef.current.x + let newY = props.playerRef.current.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 + } + + // Обработка столкновений с препятствиями + const hasCollision = props.obstacles.some(obstacle => { + return detectCollision( + { ...props.playerRef.current, x: newX, y: newY }, + obstacle + ) + }) + + // Если есть столкновение, то оставить предыдущую позицию + if (!hasCollision) { + // Ограничение движения по краям canvas + newX = Math.max( + 0, + Math.min(newX, props.canvasWidth - props.playerRef.current.width) + ) + newY = Math.max( + 0, + Math.min(newY, props.canvasHeight - props.playerRef.current.height) + ) + + // Обновляем позицию игрока в референсе + props.playerRef.current.x = newX + props.playerRef.current.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..692b85f --- /dev/null +++ b/packages/client/src/components/Game/enemy.tsx @@ -0,0 +1,43 @@ +import { getRandomEdgePosition } from './utils' +import { Enemy, Player } from '@/components/Game/gameTypes' + +export const initializeEnemies = (numberOfEnemies: number) => { + const initialEnemies: Enemy[] = [] + for (let i = 0; i < numberOfEnemies; i++) { + // количество врагов + const { x, y } = getRandomEdgePosition(800, 600) + const enemy: Enemy = { + x, + y, + width: 30, + height: 30, + speed: 1, + direction: { x: 0, y: 0 }, + } + initialEnemies.push(enemy) + } + return initialEnemies as Enemy[] +} + +export const updateEnemyPositions = ( + player: Player, + enemiesRef: React.MutableRefObject +) => { + enemiesRef.current = enemiesRef.current.map(enemy => { + const directionX = player.x - enemy.x + const directionY = player.y - enemy.y + + const magnitude = Math.sqrt(directionX ** 2 + directionY ** 2) + const normalizedX = directionX / magnitude + const normalizedY = directionY / magnitude + + const newX = enemy.x + normalizedX * enemy.speed + const newY = enemy.y + normalizedY * enemy.speed + + return { ...enemy, x: newX, y: newY } + }) +} + +export const respawnEnemies = (enemiesRef: React.MutableRefObject) => { + enemiesRef.current = initializeEnemies(5) +} diff --git a/packages/client/src/components/Game/gameLoop.tsx b/packages/client/src/components/Game/gameLoop.tsx new file mode 100644 index 0000000..d777848 --- /dev/null +++ b/packages/client/src/components/Game/gameLoop.tsx @@ -0,0 +1,46 @@ +import { HandlePlayerHit, resetPlayerPosition } from './player' +import { updateEnemyPositions, respawnEnemies } from './enemy' +import { clearCanvas, drawPlayer, drawEnemies, drawObstacles } from './utils' +import { Enemy, Obstacle, Player } from '@/components/Game/gameTypes' +import { detectEnemyCollision } from '@/components/Game/collision' + +/** + * Основной игровой цикл, который обновляет состояние игры и перерисовывает экран каждый кадр. + * @param context - Контекст рисования для Canvas. + * @param playerRef - Ссылка на текущего игрока. + * @param enemiesRef - Ссылка на массив врагов. + * @param obstacles - Массив препятствий. + * @param livesRef - Ссылка на текущее количество жизней игрока. + * @param handleGameOver - Обработчик события окончания игры. + */ +export const gameLoop = ( + context: CanvasRenderingContext2D, + playerRef: React.MutableRefObject, + enemiesRef: React.MutableRefObject, + obstacles: Obstacle[], + livesRef: React.MutableRefObject, + handleGameOver: () => void +) => { + clearCanvas(context) + + // Обновление позиций врагов + updateEnemyPositions(playerRef.current, enemiesRef) + + // Отрисовка всех игровых объектов + drawObstacles(context, obstacles) + drawPlayer(context, playerRef.current) + drawEnemies(context, enemiesRef.current) + + // Проверка на столкновения между игроком и врагами + enemiesRef.current.forEach(enemy => { + if (detectEnemyCollision(playerRef.current, enemy)) { + // Обработка столкновения: уменьшаем жизни + HandlePlayerHit( + livesRef, + handleGameOver, + () => resetPlayerPosition(playerRef), + () => respawnEnemies(enemiesRef) + ) + } + }) +} diff --git a/packages/client/src/components/Game/gameTypes.tsx b/packages/client/src/components/Game/gameTypes.tsx new file mode 100644 index 0000000..e375e95 --- /dev/null +++ b/packages/client/src/components/Game/gameTypes.tsx @@ -0,0 +1,35 @@ +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 +} + +export interface ControlsProps { + playerRef: React.MutableRefObject + obstacles: Obstacle[] + canvasWidth: number + canvasHeight: number +} 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..1de396d --- /dev/null +++ b/packages/client/src/components/Game/player.tsx @@ -0,0 +1,43 @@ +import { Player } from '@/components/Game/gameTypes' + +export const PLAYER_DEFAULT_PARAMS = { + x: 400, + y: 300, + width: 30, + height: 30, + speed: 1, + direction: { x: 0, y: 0 }, +} + +export const resetPlayerPosition = ( + playerRef: React.MutableRefObject +) => { + playerRef.current = { + ...playerRef.current, + x: PLAYER_DEFAULT_PARAMS.x, + y: PLAYER_DEFAULT_PARAMS.y, + } +} +/** + * Функция для обработки столкновения игрока с врагом. + * @param livesRef - Ссылка на текущее количество жизней игрока. + * @param handleGameOver - Обработчик события окончания игры. + * @param resetPlayerPosition - Функция для сброса позиции игрока. + * @param respawnEnemies - Функция для респауна врагов. + */ +export const HandlePlayerHit = ( + livesRef: React.MutableRefObject, + handleGameOver: () => void, + resetPlayerPosition: () => void, + respawnEnemies: () => void +) => { + const newLives = livesRef.current - 1 + + if (newLives <= 0) { + handleGameOver() + } else { + livesRef.current = newLives + resetPlayerPosition() // Сбрасываем позицию игрока + respawnEnemies() // Респавн врагов + } +} diff --git a/packages/client/src/components/Game/utils.tsx b/packages/client/src/components/Game/utils.tsx new file mode 100644 index 0000000..13cf662 --- /dev/null +++ b/packages/client/src/components/Game/utils.tsx @@ -0,0 +1,51 @@ +import { Enemy, Obstacle, Player } from '@/components/Game/gameTypes' + +export const getRandomEdgePosition = ( + canvasWidth: number, + canvasHeight: number +): { x: number; y: number } => { + const edge: 0 | 1 | 2 | 3 = Math.floor(Math.random() * 4) as 0 | 1 | 2 | 3 + + 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 } + } +} + +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.scss b/packages/client/src/pages/Game/Game.scss index 05aa4d7..74732fb 100644 --- a/packages/client/src/pages/Game/Game.scss +++ b/packages/client/src/pages/Game/Game.scss @@ -19,7 +19,7 @@ $border-color: #70842f; .game-wrapper { position: relative; width: 800px; - height: 600px; + height: auto; background-color: $c_black; border-radius: 8px; overflow: hidden; @@ -150,6 +150,10 @@ $border-color: #70842f; position: absolute; inset: 12px; background-color: $overlay-color; + + &_hide { + display: none; + } } .game-over-screen { diff --git a/packages/client/src/pages/Game/Game.tsx b/packages/client/src/pages/Game/Game.tsx index 153e642..5b7b940 100644 --- a/packages/client/src/pages/Game/Game.tsx +++ b/packages/client/src/pages/Game/Game.tsx @@ -1,4 +1,5 @@ import GameInfo from '@/assets/images/game-info.jpg' +import { Game as GamePrototype } from '@/components/Game/Game' import { Modal } from '@/components/common/Modal/Modal' import { Button } from '@/components/ui/Button/Button' import { CustomPageTitle } from '@/components/ui/CustomPageTitle/CustomPageTitle' @@ -18,6 +19,7 @@ export const Game = () => { rightButton: false, fireButton: false, }) + const [isStartedGame, setIsStartedGame] = useState(false) const pauseHandler = () => { console.log('pauseHandler') @@ -83,11 +85,19 @@ export const Game = () => {
- Воот тут игра -
-
-
+
Game Over