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, useState } from 'react'; |
| #2 | import { GAME_SIZE } from './game/constants'; |
| #3 | import { playClick } from './game/sound'; |
| #4 | |
| #5 | interface MenuProps { |
| #6 | onPlay: () => void; |
| #7 | onLeaderboard: () => void; |
| #8 | onSettings: () => void; |
| #9 | } |
| #10 | |
| #11 | const menuItems = [ |
| #12 | { label: 'Play', action: 'play' as const }, |
| #13 | { label: 'Leaderboard', action: 'leaderboard' as const }, |
| #14 | { label: 'Settings', action: 'settings' as const }, |
| #15 | ]; |
| #16 | |
| #17 | export default function Menu({ onPlay, onLeaderboard, onSettings }: MenuProps) { |
| #18 | const canvasRef = useRef<HTMLCanvasElement>(null); |
| #19 | const rafRef = useRef<number>(0); |
| #20 | const frameRef = useRef(0); |
| #21 | const [hovered, setHovered] = useState<number | null>(null); |
| #22 | |
| #23 | useEffect(() => { |
| #24 | const canvas = canvasRef.current; |
| #25 | if (!canvas) return; |
| #26 | const ctx = canvas.getContext('2d'); |
| #27 | if (!ctx) return; |
| #28 | |
| #29 | const draw = () => { |
| #30 | frameRef.current++; |
| #31 | const f = frameRef.current; |
| #32 | |
| #33 | // Dark forest background |
| #34 | ctx.fillStyle = '#0d1a0d'; |
| #35 | ctx.fillRect(0, 0, GAME_SIZE, GAME_SIZE); |
| #36 | |
| #37 | // Animated trees in bg |
| #38 | for (let i = 0; i < 12; i++) { |
| #39 | const x = ((i * 50 - f * 0.3) % 650 + 650) % 650 - 50; |
| #40 | const h = 30 + (i % 4) * 15; |
| #41 | ctx.fillStyle = '#1a3a1a'; |
| #42 | ctx.fillRect(x + 8, GAME_SIZE - 80 - h, 10, h); |
| #43 | drawMenuTree(ctx, x, GAME_SIZE - 80 - h, 26); |
| #44 | } |
| #45 | |
| #46 | // Ground |
| #47 | ctx.fillStyle = '#2a1a0a'; |
| #48 | ctx.fillRect(0, GAME_SIZE - 80, GAME_SIZE, 80); |
| #49 | ctx.fillStyle = '#3d2810'; |
| #50 | ctx.fillRect(0, GAME_SIZE - 80, GAME_SIZE, 3); |
| #51 | |
| #52 | // Title |
| #53 | const titleBob = Math.sin(f * 0.03) * 4; |
| #54 | ctx.fillStyle = '#35a035'; |
| #55 | ctx.font = 'bold 32px monospace'; |
| #56 | ctx.textAlign = 'center'; |
| #57 | ctx.fillText('ROBIN HOOD', GAME_SIZE / 2, 100 + titleBob); |
| #58 | |
| #59 | ctx.fillStyle = '#DAA520'; |
| #60 | ctx.font = 'bold 18px monospace'; |
| #61 | ctx.fillText('Endless Runner', GAME_SIZE / 2, 130 + titleBob); |
| #62 | |
| #63 | // Robin character animation |
| #64 | const robinBob = Math.sin(f * 0.06) * 3; |
| #65 | drawMenuRobin(ctx, GAME_SIZE / 2 - 14, 160 + robinBob, f); |
| #66 | |
| #67 | // Menu buttons |
| #68 | const startY = 250; |
| #69 | const spacing = 44; |
| #70 | menuItems.forEach((item, i) => { |
| #71 | const y = startY + i * spacing; |
| #72 | const isHovered = hovered === i; |
| #73 | const pulse = isHovered ? Math.sin(f * 0.15) * 2 : 0; |
| #74 | const w = 180 + pulse; |
| #75 | const h = 34; |
| #76 | |
| #77 | // Button bg |
| #78 | ctx.fillStyle = isHovered ? '#2d5a27' : '#1a3a1a'; |
| #79 | ctx.fillRect(GAME_SIZE / 2 - w / 2, y, w, h); |
| #80 | |
| #81 | // Border |
| #82 | ctx.strokeStyle = isHovered ? '#8B6914' : '#3a5a30'; |
| #83 | ctx.lineWidth = 2; |
| #84 | ctx.strokeRect(GAME_SIZE / 2 - w / 2, y, w, h); |
| #85 | |
| #86 | // Label |
| #87 | ctx.fillStyle = isHovered ? '#FFD700' : '#ccc'; |
| #88 | ctx.font = 'bold 16px monospace'; |
| #89 | ctx.textAlign = 'center'; |
| #90 | ctx.fillText(item.label, GAME_SIZE / 2, y + 22); |
| #91 | }); |
| #92 | |
| #93 | // Version |
| #94 | ctx.fillStyle = '#555'; |
| #95 | ctx.font = '10px monospace'; |
| #96 | ctx.fillText('v1.0 — tap to play', GAME_SIZE / 2, GAME_SIZE - 10); |
| #97 | |
| #98 | rafRef.current = requestAnimationFrame(draw); |
| #99 | }; |
| #100 | |
| #101 | rafRef.current = requestAnimationFrame(draw); |
| #102 | return () => cancelAnimationFrame(rafRef.current); |
| #103 | }, [hovered]); |
| #104 | |
| #105 | const handleClick = (e: React.PointerEvent) => { |
| #106 | const rect = (e.target as HTMLElement).getBoundingClientRect(); |
| #107 | const scaleY = GAME_SIZE / rect.height; |
| #108 | const y = (e.clientY - rect.top) * scaleY; |
| #109 | |
| #110 | const startY = 250; |
| #111 | const spacing = 44; |
| #112 | menuItems.forEach((item, i) => { |
| #113 | const btnY = startY + i * spacing; |
| #114 | if (y >= btnY && y <= btnY + 34) { |
| #115 | playClick(); |
| #116 | switch (item.action) { |
| #117 | case 'play': onPlay(); break; |
| #118 | case 'leaderboard': onLeaderboard(); break; |
| #119 | case 'settings': onSettings(); break; |
| #120 | } |
| #121 | } |
| #122 | }); |
| #123 | }; |
| #124 | |
| #125 | const handleMove = (e: React.PointerEvent) => { |
| #126 | const rect = (e.target as HTMLElement).getBoundingClientRect(); |
| #127 | const scaleY = GAME_SIZE / rect.height; |
| #128 | const y = (e.clientY - rect.top) * scaleY; |
| #129 | |
| #130 | const startY = 250; |
| #131 | const spacing = 44; |
| #132 | let found: number | null = null; |
| #133 | menuItems.forEach((_, i) => { |
| #134 | const btnY = startY + i * spacing; |
| #135 | if (y >= btnY && y <= btnY + 34) found = i; |
| #136 | }); |
| #137 | setHovered(found); |
| #138 | }; |
| #139 | |
| #140 | return ( |
| #141 | <canvas |
| #142 | ref={canvasRef} |
| #143 | width={GAME_SIZE} |
| #144 | height={GAME_SIZE} |
| #145 | onPointerDown={handleClick} |
| #146 | onPointerMove={handleMove} |
| #147 | onPointerLeave={() => setHovered(null)} |
| #148 | style={{ |
| #149 | width: 'min(100vw, 100vh)', |
| #150 | height: 'min(100vw, 100vh)', |
| #151 | maxWidth: '100%', |
| #152 | maxHeight: '100%', |
| #153 | imageRendering: 'pixelated', |
| #154 | cursor: 'pointer', |
| #155 | touchAction: 'none', |
| #156 | }} |
| #157 | /> |
| #158 | ); |
| #159 | } |
| #160 | |
| #161 | function drawMenuTree(ctx: CanvasRenderingContext2D, x: number, y: number, size: number) { |
| #162 | ctx.fillStyle = '#1a3a1a'; |
| #163 | // Trunk |
| #164 | ctx.fillRect(x + size * 0.35, y, size * 0.3, size * 0.4); |
| #165 | // Foliage layers |
| #166 | for (let i = 0; i < 3; i++) { |
| #167 | const ly = y - size * 0.3 - i * size * 0.22; |
| #168 | const lw = size * (1 - i * 0.2); |
| #169 | ctx.fillRect(x + (size - lw) / 2, ly, lw, size * 0.25); |
| #170 | } |
| #171 | } |
| #172 | |
| #173 | function drawMenuRobin(ctx: CanvasRenderingContext2D, x: number, y: number, frame: number) { |
| #174 | const legAnim = Math.sin(frame * 0.15) * 3; |
| #175 | // Shadow |
| #176 | ctx.fillStyle = 'rgba(0,0,0,0.3)'; |
| #177 | ctx.fillRect(x + 4, y + 42, 20, 4); |
| #178 | // Legs |
| #179 | ctx.fillStyle = '#4a3520'; |
| #180 | ctx.fillRect(x + 5, y + 30, 5, 12 + legAnim); |
| #181 | ctx.fillRect(x + 18, y + 30, 5, 12 - legAnim); |
| #182 | // Body |
| #183 | ctx.fillStyle = '#2d8a2d'; |
| #184 | ctx.fillRect(x + 3, y + 12, 22, 20); |
| #185 | ctx.fillStyle = '#35a035'; |
| #186 | ctx.fillRect(x + 4, y + 14, 20, 16); |
| #187 | // Belt |
| #188 | ctx.fillStyle = '#8B4513'; |
| #189 | ctx.fillRect(x + 3, y + 28, 22, 3); |
| #190 | ctx.fillStyle = '#DAA520'; |
| #191 | ctx.fillRect(x + 12, y + 27, 4, 5); |
| #192 | // Head |
| #193 | ctx.fillStyle = '#DEB887'; |
| #194 | ctx.fillRect(x + 6, y + 2, 16, 12); |
| #195 | // Eyes |
| #196 | ctx.fillStyle = '#000'; |
| #197 | ctx.fillRect(x + 17, y + 6, 3, 3); |
| #198 | ctx.fillStyle = '#fff'; |
| #199 | ctx.fillRect(x + 18, y + 6, 1, 1); |
| #200 | // Hat |
| #201 | ctx.fillStyle = '#2d8a2d'; |
| #202 | ctx.fillRect(x + 4, y - 3, 18, 7); |
| #203 | ctx.fillStyle = '#35a035'; |
| #204 | ctx.fillRect(x + 2, y - 1, 22, 3); |
| #205 | // Feather |
| #206 | ctx.fillStyle = '#ff4444'; |
| #207 | ctx.fillRect(x + 21, y - 8, 2, 7); |
| #208 | ctx.fillStyle = '#ff6666'; |
| #209 | ctx.fillRect(x + 22, y - 10, 2, 3); |
| #210 | // Arrow |
| #211 | ctx.fillStyle = '#8B6914'; |
| #212 | ctx.fillRect(x + 28, y + 18, 16, 2); |
| #213 | ctx.fillStyle = '#C0C0C0'; |
| #214 | ctx.fillRect(x + 44, y + 17, 4, 4); |
| #215 | } |
| #216 |