|
| 1 | +'use client'; |
| 2 | + |
| 3 | +import { useEffect, useState, useRef } from 'react'; |
| 4 | +import styles from './DOMSnake.module.scss'; |
| 5 | +import ActionButton from './ActionButton'; |
| 6 | + |
| 7 | +interface SnakeGameProps { |
| 8 | + width?: number; |
| 9 | + height?: number; |
| 10 | + startSpeed?: number; |
| 11 | +} |
| 12 | + |
| 13 | +interface Position { |
| 14 | + x: number; |
| 15 | + y: number; |
| 16 | +} |
| 17 | + |
| 18 | +const DIRECTIONS: Record<string, Position> = { |
| 19 | + ArrowUp: { x: 0, y: -1 }, |
| 20 | + ArrowDown: { x: 0, y: 1 }, |
| 21 | + ArrowLeft: { x: -1, y: 0 }, |
| 22 | + ArrowRight: { x: 1, y: 0 }, |
| 23 | +}; |
| 24 | + |
| 25 | +export default function SnakeGame(props: SnakeGameProps) { |
| 26 | + const GRID_WIDTH: number = props.width || 40; |
| 27 | + const GRID_HEIGHT: number = props.height || 20; |
| 28 | + const START_SPEED: number = props.startSpeed || 150; |
| 29 | + const INITIAL_SNAKE: Position[] = [{ x: Math.floor(GRID_WIDTH / 2), y: Math.floor(GRID_HEIGHT / 2) }]; |
| 30 | + |
| 31 | + const [snake, setSnake] = useState<Position[]>(INITIAL_SNAKE); |
| 32 | + const [food, setFood] = useState<Position | null>(null); |
| 33 | + const [direction, setDirection] = useState<Position>(DIRECTIONS.ArrowRight); |
| 34 | + const [isGameOver, setIsGameOver] = useState<boolean>(false); |
| 35 | + const [speed, setSpeed] = useState<number>(START_SPEED); |
| 36 | + const moveInterval = useRef<NodeJS.Timeout | null>(null); |
| 37 | + const nextDirection = useRef<Position>(DIRECTIONS.ArrowRight); |
| 38 | + |
| 39 | + const gameContainerRef = useRef<HTMLDivElement>(null); |
| 40 | + |
| 41 | + useEffect(() => { |
| 42 | + setFood(generateFood()); |
| 43 | + }, []); |
| 44 | + |
| 45 | + useEffect(() => { |
| 46 | + if (gameContainerRef.current) { |
| 47 | + gameContainerRef.current.tabIndex = 0; |
| 48 | + gameContainerRef.current.focus(); |
| 49 | + } |
| 50 | + }, []); |
| 51 | + |
| 52 | + const onHandleClick = (keyName: string) => { |
| 53 | + if (DIRECTIONS[keyName] && (DIRECTIONS[keyName].x !== -direction.x || DIRECTIONS[keyName].y !== -direction.y)) { |
| 54 | + nextDirection.current = DIRECTIONS[keyName]; |
| 55 | + } |
| 56 | + }; |
| 57 | + |
| 58 | + useEffect(() => { |
| 59 | + const handleKeyDown = (e: KeyboardEvent): void => { |
| 60 | + if (!gameContainerRef.current?.contains(document.activeElement)) { |
| 61 | + gameContainerRef.current?.focus(); |
| 62 | + } |
| 63 | + |
| 64 | + e.preventDefault(); |
| 65 | + e.stopPropagation(); |
| 66 | + |
| 67 | + if (DIRECTIONS[e.key] && (DIRECTIONS[e.key].x !== -direction.x || DIRECTIONS[e.key].y !== -direction.y)) { |
| 68 | + nextDirection.current = DIRECTIONS[e.key]; |
| 69 | + } |
| 70 | + }; |
| 71 | + window.addEventListener('keydown', handleKeyDown); |
| 72 | + return () => window.removeEventListener('keydown', handleKeyDown); |
| 73 | + }, [direction]); |
| 74 | + |
| 75 | + useEffect(() => { |
| 76 | + if (isGameOver) return; |
| 77 | + if (moveInterval.current) clearInterval(moveInterval.current); |
| 78 | + moveInterval.current = setInterval(moveSnake, speed); |
| 79 | + return () => { |
| 80 | + if (moveInterval.current) clearInterval(moveInterval.current); |
| 81 | + }; |
| 82 | + }, [snake, isGameOver, speed]); |
| 83 | + |
| 84 | + useEffect(() => { |
| 85 | + restartGame(); |
| 86 | + }, [isGameOver]); |
| 87 | + |
| 88 | + function moveSnake(): void { |
| 89 | + setDirection(nextDirection.current); |
| 90 | + const newHead: Position = { |
| 91 | + x: snake[0].x + nextDirection.current.x, |
| 92 | + y: snake[0].y + nextDirection.current.y, |
| 93 | + }; |
| 94 | + |
| 95 | + if (checkCollision(newHead)) { |
| 96 | + setIsGameOver(true); |
| 97 | + return; |
| 98 | + } |
| 99 | + |
| 100 | + const newSnake: Position[] = [newHead, ...snake]; |
| 101 | + if (food && newHead.x === food.x && newHead.y === food.y) { |
| 102 | + setFood(generateFood()); |
| 103 | + setSpeed((prev) => Math.max(prev - 5, 50)); |
| 104 | + } else { |
| 105 | + newSnake.pop(); |
| 106 | + } |
| 107 | + setSnake(newSnake); |
| 108 | + } |
| 109 | + |
| 110 | + function checkCollision({ x, y }: Position): boolean { |
| 111 | + return x < 0 || x >= GRID_WIDTH || y < 0 || y >= GRID_HEIGHT || snake.some((segment: Position) => segment.x === x && segment.y === y); |
| 112 | + } |
| 113 | + |
| 114 | + function generateFood(): Position { |
| 115 | + let newFood: Position; |
| 116 | + do { |
| 117 | + newFood = { |
| 118 | + x: Math.floor(Math.random() * GRID_WIDTH), |
| 119 | + y: Math.floor(Math.random() * GRID_HEIGHT), |
| 120 | + }; |
| 121 | + } while (snake.some((segment: Position) => segment.x === newFood.x && segment.y === newFood.y)); |
| 122 | + return newFood; |
| 123 | + } |
| 124 | + |
| 125 | + function restartGame(): void { |
| 126 | + setSnake(INITIAL_SNAKE); |
| 127 | + setFood(generateFood()); |
| 128 | + setDirection(DIRECTIONS.ArrowRight); |
| 129 | + setSpeed(START_SPEED); |
| 130 | + nextDirection.current = DIRECTIONS.ArrowRight; |
| 131 | + setIsGameOver(false); |
| 132 | + } |
| 133 | + |
| 134 | + return ( |
| 135 | + <div className={styles.container}> |
| 136 | + <div> |
| 137 | + <ActionButton hotkey="↑" onClick={() => onHandleClick('ArrowUp')}> |
| 138 | + Up |
| 139 | + </ActionButton> |
| 140 | + <ActionButton hotkey="↓" onClick={() => onHandleClick('ArrowDown')}> |
| 141 | + Down |
| 142 | + </ActionButton> |
| 143 | + <ActionButton hotkey="←" onClick={() => onHandleClick('ArrowLeft')}> |
| 144 | + Left |
| 145 | + </ActionButton> |
| 146 | + <ActionButton hotkey="→" onClick={() => onHandleClick('ArrowRight')}> |
| 147 | + Right |
| 148 | + </ActionButton> |
| 149 | + </div> |
| 150 | + |
| 151 | + <div |
| 152 | + className={styles.grid} |
| 153 | + ref={gameContainerRef} |
| 154 | + style={{ |
| 155 | + gridTemplateColumns: `repeat(${GRID_WIDTH}, 10px)`, |
| 156 | + gridTemplateRows: `repeat(${GRID_HEIGHT}, 20px)`, |
| 157 | + }} |
| 158 | + > |
| 159 | + {Array.from({ length: GRID_WIDTH * GRID_HEIGHT }).map((_, i: number) => { |
| 160 | + const x: number = i % GRID_WIDTH; |
| 161 | + const y: number = Math.floor(i / GRID_WIDTH); |
| 162 | + const isSnake: boolean = snake.some((segment: Position) => segment.x === x && segment.y === y); |
| 163 | + const isFood: boolean = food ? food.x === x && food.y === y : false; |
| 164 | + return <div key={i} className={`${styles.cell} ${isSnake ? styles.snake : ''} ${isFood ? styles.food : ''}`} />; |
| 165 | + })} |
| 166 | + </div> |
| 167 | + </div> |
| 168 | + ); |
| 169 | +} |
0 commit comments