repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
Robin-hood Game
stars
latest
clone command
git clone gitlawb://did:key:z6MkwMR9...adEc/robin-hood-gamegit clone gitlawb://did:key:z6MkwMR9.../robin-hood-gamec8777c8csync from playground5h ago| #1 | import { useRef, useEffect, useCallback, useState } from 'react'; |
| #2 | import { GAME_SIZE, FRAME_DURATION } from './game/constants'; |
| #3 | import { createInitialState, updateGame, jump, shoot } from './game/engine'; |
| #4 | import type { GameState } from './game/types'; |
| #5 | import { |
| #6 | drawBackground, drawRobin, drawObstacle, drawEnemy, |
| #7 | drawCoinBag, drawArrow, drawParticle, drawHUD, |
| #8 | drawGameOver, |
| #9 | } from './game/renderer'; |
| #10 | import { |
| #11 | playJump, playShoot, playHitEnemy, playKillEnemy, |
| #12 | playHurt, playCoin, playGameOver, playEnvChange, |
| #13 | } from './game/sound'; |
| #14 | import { saveScore } from './game/storage'; |
| #15 | import type { SoundEvent } from './game/types'; |
| #16 | |
| #17 | interface GameProps { |
| #18 | onMenu: () => void; |
| #19 | onScoreSaved?: () => void; |
| #20 | } |
| #21 | |
| #22 | export default function Game({ onMenu, onScoreSaved }: GameProps) { |
| #23 | const canvasRef = useRef<HTMLCanvasElement>(null); |
| #24 | const stateRef = useRef<GameState>(createInitialState()); |
| #25 | const lastTimeRef = useRef<number>(0); |
| #26 | const accumulatorRef = useRef<number>(0); |
| #27 | const rafRef = useRef<number>(0); |
| #28 | const [showNameEntry, setShowNameEntry] = useState(false); |
| #29 | const [playerName, setPlayerName] = useState(''); |
| #30 | const [scoreSaved, setScoreSaved] = useState(false); |
| #31 | |
| #32 | // Process sound events |
| #33 | const processSounds = useCallback((events: SoundEvent[]) => { |
| #34 | for (const evt of events) { |
| #35 | switch (evt) { |
| #36 | case 'jump': playJump(); break; |
| #37 | case 'shoot': playShoot(); break; |
| #38 | case 'hitEnemy': playHitEnemy(); break; |
| #39 | case 'killEnemy': playKillEnemy(); break; |
| #40 | case 'hurt': playHurt(); break; |
| #41 | case 'coin': playCoin(); break; |
| #42 | case 'gameOver': playGameOver(); break; |
| #43 | case 'envChange': playEnvChange(); break; |
| #44 | } |
| #45 | } |
| #46 | }, []); |
| #47 | |
| #48 | // Fixed-timestep game loop at 15 FPS |
| #49 | const loop = useCallback((timestamp: number) => { |
| #50 | const canvas = canvasRef.current; |
| #51 | if (!canvas) return; |
| #52 | const ctx = canvas.getContext('2d'); |
| #53 | if (!ctx) return; |
| #54 | |
| #55 | if (lastTimeRef.current === 0) { |
| #56 | lastTimeRef.current = timestamp; |
| #57 | } |
| #58 | |
| #59 | const delta = timestamp - lastTimeRef.current; |
| #60 | lastTimeRef.current = timestamp; |
| #61 | accumulatorRef.current += delta; |
| #62 | |
| #63 | // Clamp accumulator to avoid spiral of death |
| #64 | if (accumulatorRef.current > FRAME_DURATION * 5) { |
| #65 | accumulatorRef.current = FRAME_DURATION * 5; |
| #66 | } |
| #67 | |
| #68 | // Fixed update at 15 FPS |
| #69 | while (accumulatorRef.current >= FRAME_DURATION) { |
| #70 | accumulatorRef.current -= FRAME_DURATION; |
| #71 | const prev = stateRef.current; |
| #72 | stateRef.current = updateGame(stateRef.current); |
| #73 | |
| #74 | // Detect game over |
| #75 | if (!prev.gameOver && stateRef.current.gameOver) { |
| #76 | setShowNameEntry(true); |
| #77 | } |
| #78 | |
| #79 | // Process sound events |
| #80 | processSounds(stateRef.current.soundEvents); |
| #81 | } |
| #82 | |
| #83 | // Render |
| #84 | const s = stateRef.current; |
| #85 | ctx.clearRect(0, 0, GAME_SIZE, GAME_SIZE); |
| #86 | |
| #87 | // Flash effect |
| #88 | if (s.flashTimer > 0) { |
| #89 | ctx.fillStyle = `rgba(255, 0, 0, ${s.flashTimer / 30})`; |
| #90 | ctx.fillRect(0, 0, GAME_SIZE, GAME_SIZE); |
| #91 | } |
| #92 | |
| #93 | // Background |
| #94 | drawBackground(ctx, s.environment, s.frameCount, s.bgLayers); |
| #95 | |
| #96 | // Obstacles |
| #97 | for (const obs of s.obstacles) drawObstacle(ctx, obs); |
| #98 | |
| #99 | // Coin bags |
| #100 | for (const bag of s.coinBags) drawCoinBag(ctx, bag); |
| #101 | |
| #102 | // Enemies |
| #103 | for (const enemy of s.enemies) drawEnemy(ctx, enemy); |
| #104 | |
| #105 | // Arrows |
| #106 | for (const arrow of s.arrows) drawArrow(ctx, arrow); |
| #107 | |
| #108 | // Robin |
| #109 | drawRobin(ctx, s.robin); |
| #110 | |
| #111 | // Particles |
| #112 | for (const p of s.particles) drawParticle(ctx, p); |
| #113 | |
| #114 | // HUD |
| #115 | drawHUD(ctx, s.lives, s.coins, s.score, s.highScore); |
| #116 | |
| #117 | // Game over overlay |
| #118 | if (s.gameOver && !showNameEntry) { |
| #119 | drawGameOver(ctx, s.score, s.coins, s.highScore); |
| #120 | } |
| #121 | |
| #122 | rafRef.current = requestAnimationFrame(loop); |
| #123 | }, [processSounds, showNameEntry]); |
| #124 | |
| #125 | useEffect(() => { |
| #126 | rafRef.current = requestAnimationFrame(loop); |
| #127 | return () => cancelAnimationFrame(rafRef.current); |
| #128 | }, [loop]); |
| #129 | |
| #130 | // Canvas tap = jump |
| #131 | const handleCanvasTap = useCallback((e: React.PointerEvent) => { |
| #132 | e.preventDefault(); |
| #133 | if (showNameEntry) return; |
| #134 | stateRef.current = jump(stateRef.current); |
| #135 | if (stateRef.current.soundEvents.includes('jump')) { |
| #136 | playJump(); |
| #137 | } |
| #138 | }, [showNameEntry]); |
| #139 | |
| #140 | // Bow button = shoot |
| #141 | const handleShoot = useCallback((e: React.PointerEvent) => { |
| #142 | e.preventDefault(); |
| #143 | e.stopPropagation(); |
| #144 | if (showNameEntry) return; |
| #145 | const prev = stateRef.current; |
| #146 | stateRef.current = shoot(stateRef.current); |
| #147 | if (stateRef.current.soundEvents.includes('shoot')) { |
| #148 | playShoot(); |
| #149 | } |
| #150 | }, [showNameEntry]); |
| #151 | |
| #152 | // Name entry submit |
| #153 | const handleSaveScore = useCallback(() => { |
| #154 | const s = stateRef.current; |
| #155 | saveScore(playerName || 'Anonymous', s.score, s.coins); |
| #156 | setScoreSaved(true); |
| #157 | setShowNameEntry(false); |
| #158 | onScoreSaved?.(); |
| #159 | }, [playerName, onScoreSaved]); |
| #160 | |
| #161 | const handleSkipSave = useCallback(() => { |
| #162 | setShowNameEntry(false); |
| #163 | }, []); |
| #164 | |
| #165 | const handleBackToMenu = useCallback(() => { |
| #166 | onMenu(); |
| #167 | }, [onMenu]); |
| #168 | |
| #169 | return ( |
| #170 | <div |
| #171 | style={{ |
| #172 | position: 'relative', |
| #173 | width: '100%', |
| #174 | height: '100%', |
| #175 | display: 'flex', |
| #176 | alignItems: 'center', |
| #177 | justifyContent: 'center', |
| #178 | background: '#000', |
| #179 | touchAction: 'none', |
| #180 | userSelect: 'none', |
| #181 | WebkitUserSelect: 'none', |
| #182 | }} |
| #183 | > |
| #184 | <canvas |
| #185 | ref={canvasRef} |
| #186 | width={GAME_SIZE} |
| #187 | height={GAME_SIZE} |
| #188 | onPointerDown={handleCanvasTap} |
| #189 | style={{ |
| #190 | width: 'min(100vw, 100vh)', |
| #191 | height: 'min(100vw, 100vh)', |
| #192 | maxWidth: '100%', |
| #193 | maxHeight: '100%', |
| #194 | imageRendering: 'pixelated', |
| #195 | cursor: 'pointer', |
| #196 | touchAction: 'none', |
| #197 | }} |
| #198 | /> |
| #199 | |
| #200 | {/* Bow shoot button */} |
| #201 | {!showNameEntry && ( |
| #202 | <button |
| #203 | onPointerDown={handleShoot} |
| #204 | style={{ |
| #205 | position: 'absolute', |
| #206 | bottom: 'max(16px, 3vh)', |
| #207 | right: 'max(16px, 3vw)', |
| #208 | width: 'min(72px, 14vw)', |
| #209 | height: 'min(72px, 14vw)', |
| #210 | borderRadius: '50%', |
| #211 | border: '3px solid #DAA520', |
| #212 | background: 'radial-gradient(circle at 40% 35%, #4a2a0a 0%, #2a1505 100%)', |
| #213 | boxShadow: '0 0 12px rgba(218,165,32,0.4), inset 0 0 8px rgba(0,0,0,0.6)', |
| #214 | display: 'flex', |
| #215 | alignItems: 'center', |
| #216 | justifyContent: 'center', |
| #217 | cursor: 'pointer', |
| #218 | touchAction: 'none', |
| #219 | zIndex: 10, |
| #220 | padding: 0, |
| #221 | outline: 'none', |
| #222 | WebkitTapHighlightColor: 'transparent', |
| #223 | }} |
| #224 | > |
| #225 | <svg viewBox="0 0 40 40" width="65%" height="65%" fill="none"> |
| #226 | <path d="M12 6 C4 12, 4 28, 12 34" stroke="#DAA520" strokeWidth="2.5" strokeLinecap="round" /> |
| #227 | <line x1="12" y1="6" x2="12" y2="34" stroke="#C49538" strokeWidth="1.2" /> |
| #228 | <line x1="12" y1="20" x2="34" y2="20" stroke="#8B6914" strokeWidth="2" /> |
| #229 | <polygon points="34,17 40,20 34,23" fill="#C0C0C0" /> |
| #230 | <line x1="12" y1="18" x2="8" y2="16" stroke="#ff4444" strokeWidth="1.5" /> |
| #231 | <line x1="12" y1="22" x2="8" y2="24" stroke="#ff4444" strokeWidth="1.5" /> |
| #232 | </svg> |
| #233 | </button> |
| #234 | )} |
| #235 | |
| #236 | {/* Menu button */} |
| #237 | {!showNameEntry && ( |
| #238 | <button |
| #239 | onPointerDown={(e) => { e.stopPropagation(); onMenu(); }} |
| #240 | style={{ |
| #241 | position: 'absolute', |
| #242 | top: 'max(8px, 1.5vh)', |
| #243 | left: 'max(8px, 1.5vw)', |
| #244 | width: 'min(36px, 8vw)', |
| #245 | height: 'min(36px, 8vw)', |
| #246 | borderRadius: '8px', |
| #247 | border: '2px solid #555', |
| #248 | background: 'rgba(0,0,0,0.6)', |
| #249 | display: 'flex', |
| #250 | alignItems: 'center', |
| #251 | justifyContent: 'center', |
| #252 | cursor: 'pointer', |
| #253 | touchAction: 'none', |
| #254 | zIndex: 10, |
| #255 | padding: 0, |
| #256 | outline: 'none', |
| #257 | color: '#aaa', |
| #258 | fontSize: '18px', |
| #259 | fontFamily: 'monospace', |
| #260 | }} |
| #261 | > |
| #262 | ☰ |
| #263 | </button> |
| #264 | )} |
| #265 | |
| #266 | {/* Name entry overlay */} |
| #267 | {showNameEntry && ( |
| #268 | <div |
| #269 | style={{ |
| #270 | position: 'absolute', |
| #271 | inset: 0, |
| #272 | display: 'flex', |
| #273 | flexDirection: 'column', |
| #274 | alignItems: 'center', |
| #275 | justifyContent: 'center', |
| #276 | background: 'rgba(0,0,0,0.85)', |
| #277 | zIndex: 20, |
| #278 | gap: '12px', |
| #279 | padding: '20px', |
| #280 | }} |
| #281 | > |
| #282 | <div style={{ |
| #283 | color: '#ff3333', |
| #284 | fontSize: '28px', |
| #285 | fontFamily: 'monospace', |
| #286 | fontWeight: 'bold', |
| #287 | }}> |
| #288 | GAME OVER |
| #289 | </div> |
| #290 | <div style={{ |
| #291 | color: '#FFD700', |
| #292 | fontSize: '18px', |
| #293 | fontFamily: 'monospace', |
| #294 | }}> |
| #295 | Score: {stateRef.current.score} |
| #296 | </div> |
| #297 | <div style={{ |
| #298 | color: '#DAA520', |
| #299 | fontSize: '14px', |
| #300 | fontFamily: 'monospace', |
| #301 | }}> |
| #302 | Coins: {stateRef.current.coins} |
| #303 | </div> |
| #304 | |
| #305 | {!scoreSaved ? ( |
| #306 | <> |
| #307 | <div style={{ |
| #308 | color: '#aaa', |
| #309 | fontSize: '12px', |
| #310 | fontFamily: 'monospace', |
| #311 | marginTop: '8px', |
| #312 | }}> |
| #313 | Enter your name for the leaderboard: |
| #314 | </div> |
| #315 | <input |
| #316 | type="text" |
| #317 | maxLength={12} |
| #318 | placeholder="Anonymous" |
| #319 | value={playerName} |
| #320 | onChange={(e) => setPlayerName(e.target.value)} |
| #321 | onKeyDown={(e) => { if (e.key === 'Enter') handleSaveScore(); }} |
| #322 | autoFocus |
| #323 | style={{ |
| #324 | width: '200px', |
| #325 | padding: '8px 12px', |
| #326 | fontSize: '16px', |
| #327 | fontFamily: 'monospace', |
| #328 | background: '#1a2a1a', |
| #329 | color: '#fff', |
| #330 | border: '2px solid #3a5a30', |
| #331 | borderRadius: '4px', |
| #332 | textAlign: 'center', |
| #333 | outline: 'none', |
| #334 | }} |
| #335 | /> |
| #336 | <div style={{ display: 'flex', gap: '10px', marginTop: '4px' }}> |
| #337 | <button |
| #338 | onPointerDown={handleSaveScore} |
| #339 | style={{ |
| #340 | padding: '10px 24px', |
| #341 | fontSize: '14px', |
| #342 | fontFamily: 'monospace', |
| #343 | fontWeight: 'bold', |
| #344 | background: '#2d5a27', |
| #345 | color: '#FFD700', |
| #346 | border: '2px solid #DAA520', |
| #347 | borderRadius: '4px', |
| #348 | cursor: 'pointer', |
| #349 | touchAction: 'none', |
| #350 | }} |
| #351 | > |
| #352 | Save Score |
| #353 | </button> |
| #354 | <button |
| #355 | onPointerDown={handleSkipSave} |
| #356 | style={{ |
| #357 | padding: '10px 24px', |
| #358 | fontSize: '14px', |
| #359 | fontFamily: 'monospace', |
| #360 | background: '#333', |
| #361 | color: '#888', |
| #362 | border: '2px solid #555', |
| #363 | borderRadius: '4px', |
| #364 | cursor: 'pointer', |
| #365 | touchAction: 'none', |
| #366 | }} |
| #367 | > |
| #368 | Skip |
| #369 | </button> |
| #370 | </div> |
| #371 | </> |
| #372 | ) : ( |
| #373 | <div style={{ |
| #374 | color: '#35a035', |
| #375 | fontSize: '14px', |
| #376 | fontFamily: 'monospace', |
| #377 | marginTop: '10px', |
| #378 | }}> |
| #379 | Score saved! |
| #380 | </div> |
| #381 | )} |
| #382 | |
| #383 | <button |
| #384 | onPointerDown={handleBackToMenu} |
| #385 | style={{ |
| #386 | marginTop: '16px', |
| #387 | padding: '10px 32px', |
| #388 | fontSize: '14px', |
| #389 | fontFamily: 'monospace', |
| #390 | fontWeight: 'bold', |
| #391 | background: '#1a3a1a', |
| #392 | color: '#ccc', |
| #393 | border: '2px solid #3a5a30', |
| #394 | borderRadius: '4px', |
| #395 | cursor: 'pointer', |
| #396 | touchAction: 'none', |
| #397 | }} |
| #398 | > |
| #399 | Back to Menu |
| #400 | </button> |
| #401 | </div> |
| #402 | )} |
| #403 | </div> |
| #404 | ); |
| #405 | } |
| #406 |