Last active
February 5, 2026 06:23
-
-
Save juliendkim/1aac383ddd539045e3f4be1a995a2d66 to your computer and use it in GitHub Desktop.
[Game] Galaga like Shooter with React
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Galaga like Shooter</title> | |
| <!-- React & ReactDOM --> | |
| <script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script> | |
| <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script> | |
| <!-- Babel for JSX --> | |
| <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> | |
| <style> | |
| body { | |
| margin: 0; | |
| background-color: #0a0a14; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: center; | |
| align-items: center; | |
| height: 100vh; | |
| color: white; | |
| font-family: Arial, sans-serif; | |
| overflow: hidden; | |
| touch-action: none; | |
| } | |
| #root { | |
| position: relative; | |
| width: 100%; | |
| height: 100%; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| } | |
| canvas { | |
| background-color: #0a0a14; | |
| display: block; | |
| } | |
| .ui-overlay { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| pointer-events: none; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: center; | |
| align-items: center; | |
| z-index: 10; | |
| } | |
| .game-over-box { | |
| background: rgba(0, 0, 0, 0.8); | |
| padding: 40px; | |
| border-radius: 10px; | |
| text-align: center; | |
| border: 1px solid #444; | |
| pointer-events: auto; | |
| } | |
| h1 { margin: 0 0 20px 0; color: #ff3232; font-size: 48px; } | |
| p { font-size: 24px; margin-bottom: 20px; } | |
| .restart-btn { | |
| padding: 10px 30px; | |
| font-size: 20px; | |
| background: #ff3232; | |
| color: white; | |
| border: none; | |
| border-radius: 5px; | |
| cursor: pointer; | |
| } | |
| .restart-btn:hover { background: #ff5050; } | |
| .score-board { | |
| position: absolute; | |
| top: 20px; | |
| left: 20px; | |
| font-size: 24px; | |
| font-weight: bold; | |
| text-shadow: 2px 2px 2px black; | |
| z-index: 5; | |
| } | |
| #mobile-controls { | |
| position: absolute; | |
| bottom: 30px; | |
| left: 30px; | |
| width: 15vw; | |
| height: 15vw; | |
| max-width: 120px; | |
| max-height: 120px; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| pointer-events: none; | |
| z-index: 20; | |
| } | |
| .joystick-zone { | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(255, 255, 255, 0.1); | |
| border-radius: 50%; | |
| position: relative; | |
| pointer-events: auto; | |
| touch-action: none; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| border: 2px solid rgba(255, 255, 255, 0.2); | |
| } | |
| .joystick-stick { | |
| width: 40%; | |
| height: 40%; | |
| background: rgba(255, 255, 255, 0.3); | |
| border-radius: 50%; | |
| position: absolute; | |
| pointer-events: none; | |
| } | |
| @media (min-width: 768px) { | |
| #mobile-controls { | |
| display: none; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="root"></div> | |
| <script type="text/babel"> | |
| const { useState, useEffect, useRef } = React; | |
| // --- Game Logic Component --- | |
| function Game() { | |
| const canvasRef = useRef(null); | |
| const joyStickRef = useRef(null); | |
| // React state for UI overlays (Game Over, Score) | |
| const [gameState, setGameState] = useState('playing'); | |
| const [score, setScore] = useState(0); | |
| const [dimensions, setDimensions] = useState({ | |
| width: window.innerWidth, | |
| height: window.innerHeight | |
| }); | |
| // Mutable Game State (Refs) | |
| const gameStateRef = useRef('playing'); | |
| const frameId = useRef(0); | |
| const keys = useRef({}); | |
| const joyStart = useRef(null); | |
| const mobileMoveX = useRef(0); | |
| const player = useRef({ | |
| x: window.innerWidth / 2, | |
| y: window.innerHeight - 100, | |
| width: 40, | |
| height: 30, | |
| speed: 7, | |
| lastShot: 0, | |
| shootDelay: 250 | |
| }); | |
| const bullets = useRef([]); | |
| const enemies = useRef([]); | |
| const particles = useRef([]); | |
| const stars = useRef([]); | |
| const scoreRef = useRef(0); | |
| const spawnTimer = useRef(0); | |
| const spawnRate = useRef(60); | |
| // Resize Handler | |
| useEffect(() => { | |
| const handleResize = () => { | |
| const w = window.innerWidth; | |
| const h = window.innerHeight; | |
| setDimensions({ width: w, height: h }); | |
| player.current.y = h - 100; | |
| if (player.current.x > w) player.current.x = w / 2; | |
| }; | |
| window.addEventListener('resize', handleResize); | |
| return () => window.removeEventListener('resize', handleResize); | |
| }, []); | |
| // Sync Ref with State | |
| useEffect(() => { | |
| gameStateRef.current = gameState; | |
| }, [gameState]); | |
| // --- Initialization --- | |
| const initGame = () => { | |
| player.current.x = window.innerWidth / 2; | |
| player.current.y = window.innerHeight - 100; | |
| bullets.current = []; | |
| enemies.current = []; | |
| particles.current = []; | |
| scoreRef.current = 0; | |
| setScore(0); | |
| spawnRate.current = 90; | |
| spawnTimer.current = 0; | |
| mobileMoveX.current = 0; | |
| // Generate Stars | |
| stars.current = []; | |
| for(let i=0; i<100; i++) { | |
| stars.current.push({ | |
| x: Math.random() * window.innerWidth, | |
| y: Math.random() * window.innerHeight, | |
| speed: 0.5 + Math.random() * 2.5, | |
| size: 1 + Math.random(), | |
| brightness: 100 + Math.random() * 155 | |
| }); | |
| } | |
| setGameState('playing'); | |
| }; | |
| // --- Loop Management --- | |
| useEffect(() => { | |
| if (gameState === 'playing') { | |
| frameId.current = requestAnimationFrame(gameLoop); | |
| } | |
| return () => cancelAnimationFrame(frameId.current); | |
| }, [gameState, dimensions]); | |
| useEffect(() => { | |
| initGame(); | |
| const handleKeyDown = (e) => keys.current[e.code] = true; | |
| const handleKeyUp = (e) => keys.current[e.code] = false; | |
| window.addEventListener('keydown', handleKeyDown); | |
| window.addEventListener('keyup', handleKeyUp); | |
| return () => { | |
| window.removeEventListener('keydown', handleKeyDown); | |
| window.removeEventListener('keyup', handleKeyUp); | |
| }; | |
| }, []); | |
| // --- Helper Functions --- | |
| const spawnBullet = () => { | |
| bullets.current.push({ | |
| x: player.current.x, | |
| y: player.current.y - 20, | |
| width: 4, | |
| height: 10, | |
| color: '#FFFF00' | |
| }); | |
| }; | |
| const spawnEnemy = () => { | |
| const width = 30; | |
| enemies.current.push({ | |
| x: width + Math.random() * (window.innerWidth - width * 2), | |
| y: -30, | |
| width: 30, | |
| height: 30, | |
| speed: 2 + Math.random() * 3, | |
| color: '#FF3232' | |
| }); | |
| }; | |
| const createExplosion = (x, y, color, count) => { | |
| for(let i=0; i<count; i++) { | |
| particles.current.push({ | |
| x: x, | |
| y: y, | |
| vx: (Math.random() - 0.5) * 6, | |
| vy: (Math.random() - 0.5) * 6, | |
| life: 20 + Math.random() * 20, | |
| size: 2 + Math.random() * 3, | |
| color: color | |
| }); | |
| } | |
| }; | |
| const checkRectCollision = (r1, r2) => { | |
| return ( | |
| r1.x < r2.x + r2.width && | |
| r1.x + r1.width > r2.x && | |
| r1.y < r2.y + r2.height && | |
| r1.height + r1.y > r2.y | |
| ); | |
| }; | |
| // --- Main Loop --- | |
| const gameLoop = (time) => { | |
| if (gameStateRef.current !== 'playing') return; | |
| const ctx = canvasRef.current.getContext('2d'); | |
| const WIDTH = window.innerWidth; | |
| const HEIGHT = window.innerHeight; | |
| // 1. Update Logic | |
| let moveX = 0; | |
| if (keys.current['ArrowLeft']) moveX -= 1; | |
| if (keys.current['ArrowRight']) moveX += 1; | |
| if (Math.abs(mobileMoveX.current) > 0.05) { | |
| moveX = mobileMoveX.current; | |
| } | |
| player.current.x += moveX * player.current.speed; | |
| player.current.x = Math.max(player.current.width/2, Math.min(WIDTH - player.current.width/2, player.current.x)); | |
| // Shooting (Automatic) | |
| if (time - player.current.lastShot > player.current.shootDelay) { | |
| spawnBullet(); | |
| player.current.lastShot = time; | |
| } | |
| stars.current.forEach(star => { | |
| star.y += star.speed; | |
| if (star.y > HEIGHT) { | |
| star.y = 0; | |
| star.x = Math.random() * WIDTH; | |
| } | |
| }); | |
| for (let i = bullets.current.length - 1; i >= 0; i--) { | |
| const b = bullets.current[i]; | |
| b.y -= 10; | |
| if (b.y < -20) bullets.current.splice(i, 1); | |
| } | |
| spawnTimer.current++; | |
| if (spawnTimer.current >= Math.floor(spawnRate.current)) { | |
| spawnEnemy(); | |
| spawnTimer.current = 0; | |
| if (spawnRate.current > 20) spawnRate.current -= 0.1; | |
| } | |
| const pRect = { | |
| x: player.current.x - player.current.width/2 + 5, | |
| y: player.current.y - player.current.height/2 + 5, | |
| width: player.current.width - 10, | |
| height: player.current.height - 10 | |
| }; | |
| for (let i = enemies.current.length - 1; i >= 0; i--) { | |
| const e = enemies.current[i]; | |
| e.y += e.speed; | |
| if (checkRectCollision(pRect, e)) { | |
| createExplosion(player.current.x, player.current.y, '#00FFFF', 20); | |
| createExplosion(e.x + 15, e.y + 15, '#FFA500', 20); | |
| setGameState('gameover'); | |
| return; | |
| } | |
| const enemyHitbox = { | |
| x: e.x - 5, | |
| y: e.y - 5, | |
| width: e.width + 10, | |
| height: e.height + 10 | |
| }; | |
| let hit = false; | |
| for (let j = bullets.current.length - 1; j >= 0; j--) { | |
| const b = bullets.current[j]; | |
| if (checkRectCollision(b, enemyHitbox)) { | |
| bullets.current.splice(j, 1); | |
| createExplosion(e.x + 15, e.y + 15, '#FF3232', 10); | |
| scoreRef.current += 10; | |
| setScore(scoreRef.current); | |
| enemies.current.splice(i, 1); | |
| hit = true; | |
| break; | |
| } | |
| } | |
| if (!hit && e.y > HEIGHT + 50) { | |
| enemies.current.splice(i, 1); | |
| } | |
| } | |
| for (let i = particles.current.length - 1; i >= 0; i--) { | |
| const p = particles.current[i]; | |
| p.x += p.vx; | |
| p.y += p.vy; | |
| p.life--; | |
| p.size = Math.max(0, p.size - 0.1); | |
| if (p.life <= 0) particles.current.splice(i, 1); | |
| } | |
| // 2. Draw | |
| ctx.fillStyle = '#0a0a14'; | |
| ctx.fillRect(0, 0, WIDTH, HEIGHT); | |
| stars.current.forEach(star => { | |
| const c = Math.floor(star.brightness); | |
| ctx.fillStyle = `rgb(${c},${c},${c})`; | |
| ctx.fillRect(star.x, star.y, star.size, star.size); | |
| }); | |
| ctx.fillStyle = '#FFFF00'; | |
| bullets.current.forEach(b => { | |
| ctx.fillRect(b.x - b.width/2, b.y, b.width, b.height); | |
| }); | |
| ctx.fillStyle = '#00FFFF'; | |
| ctx.beginPath(); | |
| ctx.moveTo(player.current.x, player.current.y - 20); | |
| ctx.lineTo(player.current.x - 20, player.current.y + 15); | |
| ctx.lineTo(player.current.x, player.current.y + 5); | |
| ctx.lineTo(player.current.x + 20, player.current.y + 15); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| if (Math.random() > 0.5) { | |
| ctx.fillStyle = '#FFA500'; | |
| ctx.beginPath(); | |
| ctx.moveTo(player.current.x - 5, player.current.y + 10); | |
| ctx.lineTo(player.current.x, player.current.y + 25); | |
| ctx.lineTo(player.current.x + 5, player.current.y + 10); | |
| ctx.fill(); | |
| } | |
| enemies.current.forEach(e => { | |
| ctx.fillStyle = e.color; | |
| ctx.fillRect(e.x, e.y, e.width, e.height); | |
| ctx.fillStyle = '#0a0a14'; | |
| ctx.fillRect(e.x + 5, e.y + 5, 8, 8); | |
| ctx.fillRect(e.x + 17, e.y + 5, 8, 8); | |
| }); | |
| particles.current.forEach(p => { | |
| ctx.fillStyle = p.color; | |
| ctx.beginPath(); | |
| ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); | |
| ctx.fill(); | |
| }); | |
| frameId.current = requestAnimationFrame(gameLoop); | |
| }; | |
| const handleRestart = () => { | |
| initGame(); | |
| }; | |
| useEffect(() => { | |
| const handleGlobalKey = (e) => { | |
| if (gameState === 'gameover' && e.code === 'KeyR') { | |
| handleRestart(); | |
| } | |
| }; | |
| window.addEventListener('keydown', handleGlobalKey); | |
| return () => window.removeEventListener('keydown', handleGlobalKey); | |
| }, [gameState]); | |
| const setKey = (key, val) => { | |
| keys.current[key] = val; | |
| }; | |
| const handleJoyStart = (clientX, clientY) => { | |
| joyStart.current = { x: clientX, y: clientY }; | |
| }; | |
| const handleJoyMove = (clientX, clientY) => { | |
| if (!joyStart.current) return; | |
| const dx = clientX - joyStart.current.x; | |
| const moveLimit = 40; | |
| const clampedX = Math.max(-moveLimit, Math.min(moveLimit, dx)); | |
| if (joyStickRef.current) { | |
| joyStickRef.current.style.transform = `translateX(${clampedX}px)`; | |
| } | |
| mobileMoveX.current = clampedX / moveLimit; | |
| }; | |
| const handleJoyEnd = () => { | |
| joyStart.current = null; | |
| if (joyStickRef.current) { | |
| joyStickRef.current.style.transform = `translateX(0px)`; | |
| } | |
| mobileMoveX.current = 0; | |
| }; | |
| const [isMouseDragging, setIsMouseDragging] = useState(false); | |
| useEffect(() => { | |
| const onMouseMove = (e) => { | |
| if (isMouseDragging) handleJoyMove(e.clientX, e.clientY); | |
| }; | |
| const onMouseUp = () => { | |
| if (isMouseDragging) { | |
| setIsMouseDragging(false); | |
| handleJoyEnd(); | |
| } | |
| }; | |
| window.addEventListener('mousemove', onMouseMove); | |
| window.addEventListener('mouseup', onMouseUp); | |
| return () => { | |
| window.removeEventListener('mousemove', onMouseMove); | |
| window.removeEventListener('mouseup', onMouseUp); | |
| }; | |
| }, [isMouseDragging]); | |
| return ( | |
| <div style={{position: 'relative', width: '100%', height: '100%'}}> | |
| <canvas | |
| ref={canvasRef} | |
| width={dimensions.width} | |
| height={dimensions.height} | |
| /> | |
| <div className="score-board">Score: {score}</div> | |
| {gameState === 'gameover' && ( | |
| <div className="ui-overlay"> | |
| <div className="game-over-box"> | |
| <h1>GAME OVER</h1> | |
| <p>Final Score: {score}</p> | |
| <button className="restart-btn" onClick={handleRestart}>Try Again (Press R)</button> | |
| </div> | |
| </div> | |
| )} | |
| <div id="mobile-controls"> | |
| <div className="joystick-zone" | |
| onTouchStart={(e) => { e.preventDefault(); handleJoyStart(e.touches[0].clientX, e.touches[0].clientY); }} | |
| onTouchMove={(e) => { e.preventDefault(); handleJoyMove(e.touches[0].clientX, e.touches[0].clientY); }} | |
| onTouchEnd={handleJoyEnd} | |
| onMouseDown={(e) => { setIsMouseDragging(true); handleJoyStart(e.clientX, e.clientY); }} | |
| > | |
| <div ref={joyStickRef} className="joystick-stick"></div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| const root = ReactDOM.createRoot(document.getElementById('root')); | |
| root.render(<Game />); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment