Skip to content

Commit d73ede6

Browse files
authored
Merge pull request #7 from internet-development/binaryfiddler/dom-snake
Add a DOM based snake game implementation
2 parents bc48aae + ae5b4ed commit d73ede6

File tree

4 files changed

+239
-0
lines changed

4 files changed

+239
-0
lines changed

app/page.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import Text from '@components/Text';
7272
import TextArea from '@components/TextArea';
7373
import TreeView from '@components/TreeView';
7474
import UpdatingDataTable from '@components/examples/UpdatingDataTable';
75+
import ModalDOMSnake from '@root/components/modals/ModalDOMSnake';
7576

7677
export const dynamic = 'force-static';
7778

@@ -1066,6 +1067,15 @@ int main() {
10661067
<ActionButton>Render Canvas Snake</ActionButton>
10671068
</ModalTrigger>
10681069

1070+
<ModalTrigger
1071+
modal={ModalDOMSnake}
1072+
modalProps={{
1073+
buttonText: 'GAME OVER',
1074+
}}
1075+
>
1076+
<ActionButton>Render DOM Snake</ActionButton>
1077+
</ModalTrigger>
1078+
10691079
<ModalTrigger
10701080
modal={ModalCanvasPlatformer}
10711081
modalProps={{

components/DOMSnake.module.scss

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
.container {
2+
display: flex;
3+
flex-direction: column;
4+
align-items: center;
5+
gap: 4px,
6+
}
7+
8+
.grid {
9+
display: grid;
10+
&:focus {
11+
outline: 0;
12+
box-shadow: inset 0 0 0 1px var(--theme-focused-foreground);
13+
}
14+
}
15+
16+
.cell {
17+
width: 100%;
18+
height: 100%;
19+
}
20+
21+
.snake {
22+
background-color: var(--theme-text);
23+
}
24+
25+
.food {
26+
background-color: var(--theme-focused-foreground);
27+
}

components/DOMSnake.tsx

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
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+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
'use client';
2+
3+
import styles from '@components/modals/ModalCanvasSnake.module.scss';
4+
5+
import * as React from 'react';
6+
import * as Utilities from '@common/utilities';
7+
8+
import { useModals } from '@components/page/ModalContext';
9+
10+
import Button from '@components/Button';
11+
import Card from '@components/Card';
12+
import DOMSnake from '@components/DOMSnake';
13+
14+
interface ModalDOMSnakeProps {
15+
buttonText?: string | any;
16+
}
17+
18+
function ModalDOMSnake({ buttonText }: ModalDOMSnakeProps) {
19+
const { close } = useModals();
20+
21+
return (
22+
<div className={styles.root}>
23+
<Card title="DOM SNAKE">
24+
<DOMSnake height={14} width={34} />
25+
<br />
26+
<br />
27+
<Button onClick={() => close()}>{Utilities.isEmpty(buttonText) ? 'Close' : buttonText}</Button>
28+
</Card>
29+
</div>
30+
);
31+
}
32+
33+
export default ModalDOMSnake;

0 commit comments

Comments
 (0)