Skip to content

Commit b67588a

Browse files
authored
Merge pull request #231 from DataIntegrationGroup/easter-egg-1
feat(games): add Snake, Asteroids, Race Car, and Tetris game modals with search integration
2 parents 51ae803 + 9ab1ca6 commit b67588a

13 files changed

Lines changed: 2451 additions & 154 deletions
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
import { useEffect, useMemo, useRef, useState } from 'react'
2+
import {
3+
Box,
4+
Button,
5+
Dialog,
6+
DialogContent,
7+
DialogTitle,
8+
Stack,
9+
Typography,
10+
} from '@mui/material'
11+
12+
type Vector = {
13+
x: number
14+
y: number
15+
}
16+
17+
type Bullet = Vector & {
18+
id: number
19+
dx: number
20+
dy: number
21+
life: number
22+
}
23+
24+
type Asteroid = Vector & {
25+
id: number
26+
dx: number
27+
dy: number
28+
radius: number
29+
}
30+
31+
type Ship = Vector & {
32+
angle: number
33+
dx: number
34+
dy: number
35+
}
36+
37+
type AsteroidsGameModalProps = {
38+
open: boolean
39+
onClose: () => void
40+
}
41+
42+
const ARENA_WIDTH = 520
43+
const ARENA_HEIGHT = 320
44+
const SHIP_RADIUS = 12
45+
const SHOT_SPEED = 6
46+
const TURN_SPEED = 0.09
47+
const THRUST = 0.18
48+
const DRAG = 0.992
49+
const BULLET_LIFE = 60
50+
const FIRE_COOLDOWN = 10
51+
52+
const wrapPosition = (value: number, max: number) => {
53+
if (value < 0) return value + max
54+
if (value > max) return value - max
55+
return value
56+
}
57+
58+
const distance = (a: Vector, b: Vector) => Math.hypot(a.x - b.x, a.y - b.y)
59+
60+
const createAsteroids = (): Asteroid[] => [
61+
{ id: 1, x: 96, y: 88, dx: 1.1, dy: 0.7, radius: 34 },
62+
{ id: 2, x: 516, y: 124, dx: -0.9, dy: 1, radius: 28 },
63+
{ id: 3, x: 180, y: 330, dx: 0.7, dy: -0.8, radius: 30 },
64+
{ id: 4, x: 530, y: 300, dx: -1.2, dy: -0.6, radius: 22 },
65+
]
66+
67+
const createShip = (): Ship => ({
68+
x: ARENA_WIDTH / 2,
69+
y: ARENA_HEIGHT / 2,
70+
angle: -Math.PI / 2,
71+
dx: 0,
72+
dy: 0,
73+
})
74+
75+
export const AsteroidsGameModal = ({ open, onClose }: AsteroidsGameModalProps) => {
76+
const keysRef = useRef({ left: false, right: false, thrust: false, firing: false })
77+
const bulletIdRef = useRef(0)
78+
const fireCooldownRef = useRef(0)
79+
const shipRef = useRef<Ship>(createShip())
80+
const bulletsRef = useRef<Bullet[]>([])
81+
const asteroidsRef = useRef<Asteroid[]>(createAsteroids())
82+
83+
const [ship, setShip] = useState<Ship>(createShip)
84+
const [bullets, setBullets] = useState<Bullet[]>([])
85+
const [asteroids, setAsteroids] = useState<Asteroid[]>(createAsteroids)
86+
const [score, setScore] = useState(0)
87+
const [isGameOver, setIsGameOver] = useState(false)
88+
const [isVictory, setIsVictory] = useState(false)
89+
90+
const resetGame = () => {
91+
keysRef.current = { left: false, right: false, thrust: false, firing: false }
92+
bulletIdRef.current = 0
93+
fireCooldownRef.current = 0
94+
const nextShip = createShip()
95+
const nextAsteroids = createAsteroids()
96+
shipRef.current = nextShip
97+
bulletsRef.current = []
98+
asteroidsRef.current = nextAsteroids
99+
setShip(nextShip)
100+
setBullets([])
101+
setAsteroids(nextAsteroids)
102+
setScore(0)
103+
setIsGameOver(false)
104+
setIsVictory(false)
105+
}
106+
107+
useEffect(() => {
108+
if (open) {
109+
resetGame()
110+
}
111+
}, [open])
112+
113+
useEffect(() => {
114+
if (!open) return
115+
116+
const onKeyChange = (pressed: boolean) => (event: KeyboardEvent) => {
117+
const key = event.key.toLowerCase()
118+
if (['arrowleft', 'arrowright', 'arrowup', 'a', 'd', 'w', ' '].includes(key)) {
119+
event.preventDefault()
120+
}
121+
122+
if (key === 'arrowleft' || key === 'a') keysRef.current.left = pressed
123+
if (key === 'arrowright' || key === 'd') keysRef.current.right = pressed
124+
if (key === 'arrowup' || key === 'w') keysRef.current.thrust = pressed
125+
if (key === ' ') keysRef.current.firing = pressed
126+
}
127+
128+
const onKeyDown = onKeyChange(true)
129+
const onKeyUp = onKeyChange(false)
130+
131+
window.addEventListener('keydown', onKeyDown)
132+
window.addEventListener('keyup', onKeyUp)
133+
return () => {
134+
window.removeEventListener('keydown', onKeyDown)
135+
window.removeEventListener('keyup', onKeyUp)
136+
}
137+
}, [open])
138+
139+
useEffect(() => {
140+
if (!open || isGameOver || isVictory) return
141+
142+
const interval = window.setInterval(() => {
143+
const currentShip = shipRef.current
144+
let angle = currentShip.angle
145+
let dx = currentShip.dx
146+
let dy = currentShip.dy
147+
148+
if (keysRef.current.left) angle -= TURN_SPEED
149+
if (keysRef.current.right) angle += TURN_SPEED
150+
151+
if (keysRef.current.thrust) {
152+
dx += Math.cos(angle) * THRUST
153+
dy += Math.sin(angle) * THRUST
154+
}
155+
156+
dx *= DRAG
157+
dy *= DRAG
158+
159+
const nextShip = {
160+
...currentShip,
161+
angle,
162+
dx,
163+
dy,
164+
x: wrapPosition(currentShip.x + dx, ARENA_WIDTH),
165+
y: wrapPosition(currentShip.y + dy, ARENA_HEIGHT),
166+
}
167+
168+
let nextBullets = bulletsRef.current
169+
.map((bullet) => ({
170+
...bullet,
171+
x: wrapPosition(bullet.x + bullet.dx, ARENA_WIDTH),
172+
y: wrapPosition(bullet.y + bullet.dy, ARENA_HEIGHT),
173+
life: bullet.life - 1,
174+
}))
175+
.filter((bullet) => bullet.life > 0)
176+
177+
if (fireCooldownRef.current > 0) {
178+
fireCooldownRef.current -= 1
179+
}
180+
181+
if (keysRef.current.firing && fireCooldownRef.current === 0) {
182+
fireCooldownRef.current = FIRE_COOLDOWN
183+
nextBullets = [
184+
...nextBullets,
185+
{
186+
id: bulletIdRef.current += 1,
187+
x: nextShip.x + Math.cos(angle) * (SHIP_RADIUS + 4),
188+
y: nextShip.y + Math.sin(angle) * (SHIP_RADIUS + 4),
189+
dx: Math.cos(angle) * SHOT_SPEED + dx,
190+
dy: Math.sin(angle) * SHOT_SPEED + dy,
191+
life: BULLET_LIFE,
192+
},
193+
]
194+
}
195+
196+
const movedAsteroids = asteroidsRef.current.map((asteroid) => ({
197+
...asteroid,
198+
x: wrapPosition(asteroid.x + asteroid.dx, ARENA_WIDTH),
199+
y: wrapPosition(asteroid.y + asteroid.dy, ARENA_HEIGHT),
200+
}))
201+
202+
const hitBulletIds = new Set<number>()
203+
const hitAsteroidIds = new Set<number>()
204+
205+
for (const bullet of nextBullets) {
206+
const hit = movedAsteroids.find((asteroid) => distance(bullet, asteroid) < asteroid.radius)
207+
if (hit) {
208+
hitBulletIds.add(bullet.id)
209+
hitAsteroidIds.add(hit.id)
210+
}
211+
}
212+
213+
const remainingBullets = nextBullets.filter((bullet) => !hitBulletIds.has(bullet.id))
214+
const remainingAsteroids = movedAsteroids.filter((asteroid) => !hitAsteroidIds.has(asteroid.id))
215+
216+
if (hitAsteroidIds.size > 0) {
217+
setScore((currentScore) => currentScore + hitAsteroidIds.size * 100)
218+
}
219+
220+
if (remainingAsteroids.some((asteroid) => distance(nextShip, asteroid) < asteroid.radius + SHIP_RADIUS - 2)) {
221+
setIsGameOver(true)
222+
} else if (remainingAsteroids.length === 0) {
223+
setIsVictory(true)
224+
}
225+
226+
shipRef.current = nextShip
227+
bulletsRef.current = remainingBullets
228+
asteroidsRef.current = remainingAsteroids
229+
setShip(nextShip)
230+
setBullets(remainingBullets)
231+
setAsteroids(remainingAsteroids)
232+
}, 16)
233+
234+
return () => window.clearInterval(interval)
235+
}, [isGameOver, isVictory, open])
236+
237+
const shipPoints = useMemo(() => {
238+
const nose = `${ship.x + Math.cos(ship.angle) * 16},${ship.y + Math.sin(ship.angle) * 16}`
239+
const left = `${ship.x + Math.cos(ship.angle + 2.45) * 13},${ship.y + Math.sin(ship.angle + 2.45) * 13}`
240+
const right = `${ship.x + Math.cos(ship.angle - 2.45) * 13},${ship.y + Math.sin(ship.angle - 2.45) * 13}`
241+
return `${nose} ${left} ${right}`
242+
}, [ship.angle, ship.x, ship.y])
243+
244+
return (
245+
<Dialog
246+
open={open}
247+
onClose={onClose}
248+
fullWidth
249+
maxWidth="md"
250+
aria-labelledby="asteroids-game-title"
251+
sx={{
252+
'& .MuiDialog-paper': {
253+
maxHeight: 'calc(100vh - 32px)',
254+
},
255+
}}
256+
>
257+
<DialogTitle id="asteroids-game-title">Asteroids</DialogTitle>
258+
<DialogContent sx={{ pt: 1, overflow: 'hidden' }}>
259+
<Stack spacing={2}>
260+
<Stack direction="row" justifyContent="space-between" alignItems="center">
261+
<Typography variant="body2" color="text.secondary">
262+
Score: {score}
263+
</Typography>
264+
<Button variant="outlined" size="small" onClick={resetGame}>
265+
Restart
266+
</Button>
267+
</Stack>
268+
269+
<Box
270+
role="img"
271+
aria-label="Asteroids game board"
272+
sx={{
273+
width: '100%',
274+
maxWidth: ARENA_WIDTH,
275+
mx: 'auto',
276+
borderRadius: 2,
277+
overflow: 'hidden',
278+
border: '1px solid',
279+
borderColor: 'divider',
280+
bgcolor: '#020617',
281+
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.06)',
282+
}}
283+
>
284+
<svg viewBox={`0 0 ${ARENA_WIDTH} ${ARENA_HEIGHT}`} width="100%" height="100%" style={{ display: 'block' }}>
285+
<rect width={ARENA_WIDTH} height={ARENA_HEIGHT} fill="#020617" />
286+
{asteroids.map((asteroid) => (
287+
<circle
288+
key={asteroid.id}
289+
cx={asteroid.x}
290+
cy={asteroid.y}
291+
r={asteroid.radius}
292+
fill="none"
293+
stroke="#94a3b8"
294+
strokeWidth="3"
295+
/>
296+
))}
297+
{bullets.map((bullet) => (
298+
<circle key={bullet.id} cx={bullet.x} cy={bullet.y} r="3" fill="#f8fafc" />
299+
))}
300+
<polygon points={shipPoints} fill="none" stroke="#38bdf8" strokeWidth="3" />
301+
{keysRef.current.thrust && !isGameOver && !isVictory && (
302+
<line
303+
x1={ship.x + Math.cos(ship.angle + Math.PI) * 8}
304+
y1={ship.y + Math.sin(ship.angle + Math.PI) * 8}
305+
x2={ship.x + Math.cos(ship.angle + Math.PI) * 18}
306+
y2={ship.y + Math.sin(ship.angle + Math.PI) * 18}
307+
stroke="#f97316"
308+
strokeWidth="3"
309+
/>
310+
)}
311+
</svg>
312+
</Box>
313+
314+
<Typography variant="caption" color="text.secondary">
315+
Use left/right or A/D to rotate, up or W to thrust, and space to fire. Clear every asteroid without colliding.
316+
</Typography>
317+
318+
{(isGameOver || isVictory) && (
319+
<Box
320+
sx={{
321+
borderRadius: 2,
322+
px: 1.5,
323+
py: 1.25,
324+
bgcolor: isVictory ? 'success.light' : 'error.light',
325+
color: isVictory ? 'success.contrastText' : 'error.contrastText',
326+
}}
327+
>
328+
<Typography variant="body2" fontWeight={700}>
329+
{isVictory ? 'You cleared the field' : 'Ship destroyed'}
330+
</Typography>
331+
<Typography variant="caption" sx={{ display: 'block', opacity: 0.9 }}>
332+
{isVictory ? `Final score: ${score}.` : `Final score: ${score}.`} Restart to play again.
333+
</Typography>
334+
</Box>
335+
)}
336+
</Stack>
337+
</DialogContent>
338+
</Dialog>
339+
)
340+
}
341+
342+
export default AsteroidsGameModal

0 commit comments

Comments
 (0)