import React, { useState, useEffect, useRef } from 'react'; import { Play, Pause, Music, Settings, HelpCircle, Volume2, VolumeX } from 'lucide-react'; // Main App Component export default function NeonRhythmRoads() { const [gameState, setGameState] = useState('menu'); // menu, playing, paused, gameOver const [score, setScore] = useState(0); const [combo, setCombo] = useState(0); const [highScore, setHighScore] = useState(0); const [currentTrack, setCurrentTrack] = useState(0); const [isMuted, setIsMuted] = useState(false); const [showHelp, setShowHelp] = useState(false); const [showSettings, setShowSettings] = useState(false); const [calibration, setCalibration] = useState(0); const [difficulty, setDifficulty] = useState('medium'); const canvasRef = useRef(null); const audioContextRef = useRef(null); const analyserRef = useRef(null); const audioElementRef = useRef(null); const animationFrameRef = useRef(null); const lastBeatTimeRef = useRef(0); const gridRef = useRef([]); const upcomingPiecesRef = useRef([]); const beatTimingsRef = useRef([]); const currentBeatIndexRef = useRef(0); // Audio analysis data const frequencyDataRef = useRef(null); const timeDataRef = useRef(null); const beatDetectedRef = useRef(false); const energyHistoryRef = useRef([]); const frequencyBandsRef = useRef({ bass: { sum: 0, average: 0, peak: 0 }, midrange: { sum: 0, average: 0, peak: 0 }, treble: { sum: 0, average: 0, peak: 0 } }); const musicTracks = [ { id: 1, title: "Neon Pulse", artist: "SynthCity", bpm: 128, src: "https://example.com/tracks/neon-pulse.mp3" }, { id: 2, title: "Electric Avenue", artist: "Beat Builders", bpm: 110, src: "https://example.com/tracks/electric-avenue.mp3" }, { id: 3, title: "Midnight Drive", artist: "Rhythm Roads", bpm: 95, src: "https://example.com/tracks/midnight-drive.mp3" }, ]; const roadPieces = [ { type: 'straight', points: 100, color: '#00ffff' }, { type: 'curve', points: 150, color: '#ff00ff' }, { type: 'intersection', points: 200, color: '#ffff00' }, { type: 'special', points: 300, color: '#ff5500', bonus: 'doublePoints' } ]; // Initialize the game useEffect(() => { // Set up canvas and grid if (gameState === 'playing' && canvasRef.current) { const canvas = canvasRef.current; const ctx = canvas.getContext('2d'); // Initialize grid gridRef.current = Array(10).fill().map(() => Array(10).fill(null)); // Generate initial upcoming pieces generateUpcomingPieces(); // Set up audio context and analyzer setupAudio(); // Start game loop startGameLoop(ctx); } return () => { if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); } if (audioContextRef.current && audioContextRef.current.state === 'running') { audioContextRef.current.close(); } }; }, [gameState]); // Setup audio analysis const setupAudio = () => { if (!audioContextRef.current) { audioContextRef.current = new (window.AudioContext || window.webkitAudioContext)(); analyserRef.current = audioContextRef.current.createAnalyser(); // Configure analyzer for detailed frequency analysis analyserRef.current.fftSize = 2048; // Higher for more detailed frequency data const bufferLength = analyserRef.current.frequencyBinCount; // Create data arrays for frequency and time domain analysis frequencyDataRef.current = new Uint8Array(bufferLength); timeDataRef.current = new Uint8Array(bufferLength); if (audioElementRef.current) { const source = audioContextRef.current.createMediaElementSource(audioElementRef.current); source.connect(analyserRef.current); analyserRef.current.connect(audioContextRef.current.destination); } // Pre-calculate beat timings based on BPM const track = musicTracks[currentTrack]; const beatInterval = 60 / track.bpm * 1000; // in milliseconds const trackDuration = 180000; // Assume 3 minutes for demo beatTimingsRef.current = []; for (let time = 0; time < trackDuration; time += beatInterval) { beatTimingsRef.current.push(time + calibration); } // Initialize energy history for beat detection energyHistoryRef.current = Array(30).fill(0); } }; // Analyze audio data const analyzeAudio = () => { if (!analyserRef.current || !frequencyDataRef.current || !timeDataRef.current) return; // Get frequency and time domain data analyserRef.current.getByteFrequencyData(frequencyDataRef.current); analyserRef.current.getByteTimeDomainData(timeDataRef.current); const bufferLength = analyserRef.current.frequencyBinCount; // Calculate energy levels for different frequency bands // Bass: ~20-250Hz, Midrange: ~250-2000Hz, Treble: ~2000-20000Hz const bassRange = Math.floor(bufferLength * 0.1); // First 10% of frequencies const midRange = Math.floor(bufferLength * 0.5); // Next 40% of frequencies let bassSum = 0; let midSum = 0; let trebleSum = 0; // Calculate sums for each frequency range for (let i = 0; i < bassRange; i++) { bassSum += frequencyDataRef.current[i]; } for (let i = bassRange; i < midRange; i++) { midSum += frequencyDataRef.current[i]; } for (let i = midRange; i < bufferLength; i++) { trebleSum += frequencyDataRef.current[i]; } // Calculate averages const bassAvg = bassSum / bassRange; const midAvg = midSum / (midRange - bassRange); const trebleAvg = trebleSum / (bufferLength - midRange); // Update frequency bands data with smoothing const smoothingFactor = 0.8; // Higher = more smoothing frequencyBandsRef.current = { bass: { sum: bassSum, average: frequencyBandsRef.current.bass.average * smoothingFactor + bassAvg * (1 - smoothingFactor), peak: Math.max(frequencyBandsRef.current.bass.peak * 0.95, bassAvg) // Decay peak slowly }, midrange: { sum: midSum, average: frequencyBandsRef.current.midrange.average * smoothingFactor + midAvg * (1 - smoothingFactor), peak: Math.max(frequencyBandsRef.current.midrange.peak * 0.95, midAvg) }, treble: { sum: trebleSum, average: frequencyBandsRef.current.treble.average * smoothingFactor + trebleAvg * (1 - smoothingFactor), peak: Math.max(frequencyBandsRef.current.treble.peak * 0.95, trebleAvg) } }; // Calculate overall energy for beat detection const totalEnergy = (bassSum + midSum + trebleSum) / bufferLength; // Update energy history energyHistoryRef.current.push(totalEnergy); energyHistoryRef.current.shift(); // Calculate average energy over history const avgEnergy = energyHistoryRef.current.reduce((sum, val) => sum + val, 0) / energyHistoryRef.current.length; // Beat detection - look for significant energy spikes const energyRatio = totalEnergy / (avgEnergy + 1); // Add 1 to avoid division by zero const beatThreshold = 1.5; // Adjust based on testing // Reset beat detection flag beatDetectedRef.current = false; // Check if we have a beat if (energyRatio > beatThreshold && totalEnergy > 50) { // Check if enough time has passed since last beat to avoid double-triggering const now = audioElementRef.current ? audioElementRef.current.currentTime * 1000 : Date.now(); const timeSinceLastBeat = now - lastBeatTimeRef.current; // Minimum time between beats based on BPM (with some margin) const minBeatInterval = (60 / musicTracks[currentTrack].bpm * 1000) * 0.5; if (timeSinceLastBeat > minBeatInterval) { beatDetectedRef.current = true; lastBeatTimeRef.current = now; } } }; // Generate upcoming road pieces const generateUpcomingPieces = () => { while (upcomingPiecesRef.current.length < 5) { const randomIndex = Math.floor(Math.random() * roadPieces.length); const specialChance = Math.random(); // Special pieces are rarer if (roadPieces[randomIndex].type === 'special' && specialChance < 0.7) { continue; } upcomingPiecesRef.current.push({ ...roadPieces[randomIndex], rotation: Math.floor(Math.random() * 4) * 90 }); } }; // Main game loop const startGameLoop = (ctx) => { const gameLoop = () => { if (gameState !== 'playing') return; // Analyze audio data analyzeAudio(); // Clear canvas ctx.fillStyle = '#000000'; ctx.fillRect(0, 0, canvasRef.current.width, canvasRef.current.height); // Draw city background drawCityBackground(ctx); // Draw isometric grid drawIsometricGrid(ctx); // Draw placed road pieces drawRoadPieces(ctx); // Draw upcoming pieces queue drawUpcomingPieces(ctx); // Draw rhythm guide drawRhythmGuide(ctx); // Check for beats checkBeats(); // Request next frame animationFrameRef.current = requestAnimationFrame(gameLoop); }; gameLoop(); }; // Draw city background with audio-reactive lights const drawCityBackground = (ctx) => { const canvas = canvasRef.current; const width = canvas.width; const height = canvas.height; // Get audio analysis data const bands = frequencyBandsRef.current; const beatDetected = beatDetectedRef.current; const frequencyData = frequencyDataRef.current; // Create a starry background const starCount = 100; for (let i = 0; i < starCount; i++) { const x = Math.random() * width; const y = Math.random() * (height * 0.7); // Stars only in the upper part // Make stars pulse with high frequencies const starSize = 1 + (bands.treble.average / 255) * 2; const starBrightness = 0.5 + (bands.treble.average / 255) * 0.5; ctx.fillStyle = `rgba(255, 255, 255, ${starBrightness})`; ctx.beginPath(); ctx.arc(x, y, starSize, 0, Math.PI * 2); ctx.fill(); } // Draw audio-reactive skyline gradient const bassIntensity = bands.bass.average / 255; const midIntensity = bands.midrange.average / 255; const trebleIntensity = bands.treble.average / 255; // Create dynamic gradient colors based on frequency bands const skyGradient = ctx.createLinearGradient(0, 0, 0, height * 0.7); skyGradient.addColorStop(0, `rgba(${10 + bassIntensity * 20}, ${5 + midIntensity * 15}, ${30 + trebleIntensity * 50}, 1)`); skyGradient.addColorStop(1, `rgba(${20 + midIntensity * 40}, ${5 + bassIntensity * 20}, ${40 + trebleIntensity * 30}, 1)`); ctx.fillStyle = skyGradient; ctx.fillRect(0, 0, width, height * 0.7); // Draw frequency visualizer in the background if (frequencyData) { const barWidth = width / (frequencyData.length / 4); const visualizerHeight = height * 0.2; const visualizerY = height * 0.65; for (let i = 0; i < frequencyData.length / 4; i++) { const barHeight = (frequencyData[i] / 255) * visualizerHeight; // Color based on frequency (bass = red/pink, mids = purple, highs = blue/cyan) let barColor; if (i < frequencyData.length / 16) { barColor = `rgba(255, ${50 + i * 2}, ${100 + i * 4}, 0.3)`; // Bass range } else if (i < frequencyData.length / 8) { barColor = `rgba(${255 - (i - frequencyData.length/16) * 4}, 50, ${150 + (i - frequencyData.length/16) * 2}, 0.3)`; // Mid range } else { barColor = `rgba(50, ${100 + (i - frequencyData.length/8) * 2}, 255, 0.3)`; // High range } ctx.fillStyle = barColor; ctx.fillRect(i * barWidth, visualizerY - barHeight, barWidth, barHeight); } } // Draw buildings with audio-reactive lights const buildingCount = 20; const buildingBaseColors = [ { r: 10, g: 10, b: 30 }, // Dark blue { r: 20, g: 5, b: 25 }, // Dark purple { r: 25, g: 5, b: 15 } // Dark red ]; for (let i = 0; i < buildingCount; i++) { const x = (i / buildingCount) * width * 1.2 - width * 0.1; // Spread across with some overflow // Make building heights react to mid frequencies const heightFactor = 0.8 + (frequencyData ? (frequencyData[i * 2 + 20] / 255) * 0.4 : 0.2); const buildingHeight = (100 + Math.random() * 150) * heightFactor; const width = 30 + Math.random() * 50; // Select base color and modify with audio reactivity const baseColor = buildingBaseColors[i % buildingBaseColors.length]; const r = baseColor.r + bassIntensity * 15; const g = baseColor.g + midIntensity * 10; const b = baseColor.b + trebleIntensity * 25; // Building ctx.fillStyle = `rgb(${r}, ${g}, ${b})`; ctx.fillRect(x, height - buildingHeight, width, buildingHeight); // Windows with audio-reactive lights const windowSize = 5; const windowSpacing = 12; const windowRows = Math.floor(buildingHeight / windowSpacing) - 1; const windowCols = Math.floor(width / windowSpacing) - 1; for (let row = 0; row < windowRows; row++) { for (let col = 0; col < windowCols; col++) { const wx = x + (col + 1) * windowSpacing; const wy = height - buildingHeight + (row + 1) * windowSpacing; // Window visibility based on audio const windowFreqIndex = (row * windowCols + col) % (frequencyData ? frequencyData.length / 8 : 10); const freqIntensity = frequencyData ? frequencyData[windowFreqIndex + 10] / 255 : 0.5; // Determine if window should be lit based on frequency and randomness const isLit = Math.random() < (0.3 + freqIntensity * 0.5); if (isLit) { // Window color based on frequency bands let windowColor; if (beatDetected && Math.random() < 0.3) { // Bright flash on beat windowColor = `rgba(255, 255, 255, 0.9)`; } else if (row < windowRows * 0.3) { // Upper floors - cyan/blue (treble reactive) windowColor = `rgba(100, 200, ${150 + trebleIntensity * 105}, ${0.7 + trebleIntensity * 0.3})`; } else if (row < windowRows * 0.7) { // Middle floors - purple/pink (mid reactive) windowColor = `rgba(${150 + midIntensity * 105}, 100, 255, ${0.7 + midIntensity * 0.3})`; } else { // Lower floors - red/orange (bass reactive) windowColor = `rgba(255, ${100 + bassIntensity * 155}, 50, ${0.7 + bassIntensity * 0.3})`; } ctx.fillStyle = windowColor; ctx.fillRect(wx, wy, windowSize, windowSize); // Add glow effect for some windows if (freqIntensity > 0.7 || (beatDetected && Math.random() < 0.2)) { const glowSize = 5 + freqIntensity * 5; ctx.shadowColor = windowColor; ctx.shadowBlur = glowSize; ctx.fillRect(wx, wy, windowSize, windowSize); ctx.shadowBlur = 0; } } } } // Add rooftop lights that pulse with the beat if (i % 3 === 0) { const antennaHeight = 15 + Math.random() * 20; const antennaWidth = 2; const antennaX = x + width / 2 - antennaWidth / 2; const antennaY = height - buildingHeight - antennaHeight; // Antenna ctx.fillStyle = '#333'; ctx.fillRect(antennaX, antennaY, antennaWidth, antennaHeight); // Blinking light intensity based on beat const blinkIntensity = beatDetected ? 1 : 0.3 + 0.7 * Math.sin(Date.now() / 500); // Beacon light ctx.fillStyle = `rgba(255, 50, 50, ${blinkIntensity})`; ctx.beginPath(); ctx.arc(antennaX + antennaWidth / 2, antennaY, 3, 0, Math.PI * 2); ctx.fill(); // Light glow if (blinkIntensity > 0.7) { ctx.shadowColor = 'rgba(255, 50, 50, 0.8)'; ctx.shadowBlur = 10 * blinkIntensity; ctx.beginPath(); ctx.arc(antennaX + antennaWidth / 2, antennaY, 3, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; } } } // Draw ground reflection with audio reactivity const reflectionGradient = ctx.createLinearGradient(0, height * 0.7, 0, height); reflectionGradient.addColorStop(0, `rgba(${20 + bassIntensity * 40}, ${5 + midIntensity * 20}, ${40 + trebleIntensity * 30}, 0.8)`); reflectionGradient.addColorStop(1, `rgba(0, 0, 10, 0.1)`); ctx.fillStyle = reflectionGradient; ctx.fillRect(0, height * 0.7, width, height * 0.3); // Draw horizontal light streaks that react to music const streakCount = 5 + Math.floor(bands.treble.average / 255 * 10); for (let i = 0; i < streakCount; i++) { const y = height * 0.7 + Math.random() * (height * 0.3); const streakWidth = 50 + Math.random() * 200 * (1 + bassIntensity); const x = Math.random() * width; // Color based on frequency bands const hue = Math.floor( (bands.bass.average / 255) * 300 + // Red to purple (bands.midrange.average / 255) * 60 // Add some yellow/green ); const streakOpacity = 0.1 + Math.random() * 0.3 * (1 + midIntensity); ctx.fillStyle = `hsla(${hue}, 100%, 70%, ${streakOpacity})`; // Draw streak with blur for glow effect ctx.shadowColor = `hsla(${hue}, 100%, 70%, 0.8)`; ctx.shadowBlur = 5 + 10 * trebleIntensity; ctx.beginPath(); ctx.ellipse(x, y, streakWidth, 1 + bassIntensity * 2, 0, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; } // Draw beat-reactive pulse overlay if (beatDetected) { const pulseOpacity = 0.2; ctx.fillStyle = `rgba(255, 100, 255, ${pulseOpacity})`; ctx.fillRect(0, 0, width, height); } // Draw subtle vignette effect const vignetteGradient = ctx.createRadialGradient( width / 2, height / 2, 0, width / 2, height / 2, width * 0.7 ); vignetteGradient.addColorStop(0, 'rgba(0, 0, 0, 0)'); vignetteGradient.addColorStop(1, 'rgba(0, 0, 0, 0.7)'); ctx.fillStyle = vignetteGradient; ctx.fillRect(0, 0, width, height); }; // Draw isometric grid const drawIsometricGrid = (ctx) => { const tileWidth = 40; const tileHeight = 20; const gridSize = 10; const offsetX = canvasRef.current.width / 2; const offsetY = 150; // Draw grid lines ctx.strokeStyle = '#333333'; ctx.lineWidth = 0.5; for (let y = 0; y < gridSize; y++) { for (let x = 0; x < gridSize; x++) { const isoX = (x - y) * tileWidth / 2 + offsetX; const isoY = (x + y) * tileHeight / 2 + offsetY; // Draw tile outline ctx.beginPath(); ctx.moveTo(isoX, isoY); ctx.lineTo(isoX + tileWidth / 2, isoY + tileHeight / 2); ctx.lineTo(isoX, isoY + tileHeight); ctx.lineTo(isoX - tileWidth / 2, isoY + tileHeight / 2); ctx.closePath(); ctx.stroke(); } } }; // Draw placed road pieces const drawRoadPieces = (ctx) => { const tileWidth = 40; const tileHeight = 20; const offsetX = canvasRef.current.width / 2; const offsetY = 150; // Get audio data for visual effects const bands = frequencyBandsRef.current; const beatDetected = beatDetectedRef.current; for (let y = 0; y < gridRef.current.length; y++) { for (let x = 0; x < gridRef.current[y].length; x++) { const piece = gridRef.current[y][x]; if (!piece) continue; const isoX = (x - y) * tileWidth / 2 + offsetX; const isoY = (x + y) * tileHeight / 2 + offsetY; // Make road pieces react to audio const bassIntensity = bands.bass.average / 255; const midIntensity = bands.midrange.average / 255; // Modify color based on audio const baseColor = piece.color; let roadColor = baseColor; // Make roads pulse with the beat if (beatDetected) { roadColor = '#ffffff'; // Flash white on beat } else if (piece.type === 'special') { // Make special pieces more reactive const hue = parseInt(baseColor.slice(1), 16); const hueShift = Math.sin(Date.now() / 500) * 20; roadColor = `hsl(${(hue + hueShift) % 360}, 100%, ${50 + midIntensity * 30}%)`; } // Draw road piece ctx.fillStyle = roadColor; ctx.beginPath(); ctx.moveTo(isoX, isoY); ctx.lineTo(isoX + tileWidth / 2, isoY + tileHeight / 2); ctx.lineTo(isoX, isoY + tileHeight); ctx.lineTo(isoX - tileWidth / 2, isoY + tileHeight / 2); ctx.closePath(); ctx.fill(); // Add glow effect based on audio intensity if (bassIntensity > 0.6 || beatDetected) { const glowSize = 5 + bassIntensity * 10; ctx.shadowColor = roadColor; ctx.shadowBlur = glowSize; ctx.beginPath(); ctx.moveTo(isoX, isoY); ctx.lineTo(isoX + tileWidth / 2, isoY + tileHeight / 2); ctx.lineTo(isoX, isoY + tileHeight); ctx.lineTo(isoX - tileWidth / 2, isoY + tileHeight / 2); ctx.closePath(); ctx.fill(); ctx.shadowBlur = 0; } // Draw road markings based on piece type and rotation ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 2; ctx.beginPath(); if (piece.type === 'straight') { if (piece.rotation === 0 || piece.rotation === 180) { ctx.moveTo(isoX - tileWidth / 2, isoY + tileHeight / 2); ctx.lineTo(isoX + tileWidth / 2, isoY + tileHeight / 2); } else { ctx.moveTo(isoX, isoY); ctx.lineTo(isoX, isoY + tileHeight); } } else if (piece.type === 'curve') { // Draw curved road based on rotation const radius = tileWidth / 2; if (piece.rotation === 0) { ctx.arc(isoX - tileWidth / 2, isoY, radius, 0, Math.PI / 2); } else if (piece.rotation === 90) { ctx.arc(isoX, isoY, radius, Math.PI / 2, Math.PI); } else if (piece.rotation === 180) { ctx.arc(isoX, isoY + tileHeight, radius, Math.PI, 3 * Math.PI / 2); } else { ctx.arc(isoX + tileWidth / 2, isoY + tileHeight / 2, radius, 3 * Math.PI / 2, 2 * Math.PI); } } else if (piece.type === 'intersection') { ctx.moveTo(isoX - tileWidth / 2, isoY + tileHeight / 2); ctx.lineTo(isoX + tileWidth / 2, isoY + tileHeight / 2); ctx.moveTo(isoX, isoY); ctx.lineTo(isoX, isoY + tileHeight); } else if (piece.type === 'special') { // Draw special piece with glowing effect ctx.moveTo(isoX - tileWidth / 2, isoY + tileHeight / 2); ctx.lineTo(isoX + tileWidth / 2, isoY + tileHeight / 2); ctx.moveTo(isoX, isoY); ctx.lineTo(isoX, isoY + tileHeight); // Add pulsing glow const now = Date.now(); const glowSize = 5 + 3 * Math.sin(now / 200) + midIntensity * 10; ctx.shadowColor = piece.color; ctx.shadowBlur = glowSize; } ctx.stroke(); ctx.shadowBlur = 0; } } }; // Draw upcoming pieces queue const drawUpcomingPieces = (ctx) => { const queueX = canvasRef.current.width - 120; const queueY = 150; const pieceSize = 50; const spacing = 70; ctx.fillStyle = '#333333'; ctx.fillRect(queueX - 10, queueY - 10, pieceSize + 20, spacing * 5 + 20); ctx.font = '16px Inter, sans-serif'; ctx.fillStyle = '#ffffff'; ctx.fillText('Next Pieces', queueX + 5, queueY - 20); for (let i = 0; i < Math.min(5, upcomingPiecesRef.current.length); i++) { const piece = upcomingPiecesRef.current[i]; const x = queueX + pieceSize / 2; const y = queueY + i * spacing + pieceSize / 2; // Draw piece background ctx.fillStyle = piece.color; ctx.beginPath(); ctx.arc(x, y, pieceSize / 2, 0, Math.PI * 2); ctx.fill(); // Draw piece icon based on type ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 3; ctx.beginPath(); if (piece.type === 'straight') { if (piece.rotation === 0 || piece.rotation === 180) { ctx.moveTo(x - pieceSize / 2 + 5, y); ctx.lineTo(x + pieceSize / 2 - 5, y); } else { ctx.moveTo(x, y - pieceSize / 2 + 5); ctx.lineTo(x, y + pieceSize / 2 - 5); } } else if (piece.type === 'curve') { const radius = pieceSize / 3; if (piece.rotation === 0) { ctx.arc(x - radius, y - radius, radius, 0, Math.PI / 2); } else if (piece.rotation === 90) { ctx.arc(x + radius, y - radius, radius, Math.PI / 2, Math.PI); } else if (piece.rotation === 180) { ctx.arc(x + radius, y + radius, radius, Math.PI, 3 * Math.PI / 2); } else { ctx.arc(x - radius, y + radius, radius, 3 * Math.PI / 2, 2 * Math.PI); } } else if (piece.type === 'intersection') { ctx.moveTo(x - pieceSize / 2 + 5, y); ctx.lineTo(x + pieceSize / 2 - 5, y); ctx.moveTo(x, y - pieceSize / 2 + 5); ctx.lineTo(x, y + pieceSize / 2 - 5); } else if (piece.type === 'special') { // Star shape for special pieces const spikes = 5; const outerRadius = pieceSize / 2 - 5; const innerRadius = pieceSize / 4; for (let j = 0; j < spikes * 2; j++) { const radius = j % 2 === 0 ? outerRadius : innerRadius; const angle = j * Math.PI / spikes; const pointX = x + radius * Math.cos(angle); const pointY = y + radius * Math.sin(angle); if (j === 0) { ctx.moveTo(pointX, pointY); } else { ctx.lineTo(pointX, pointY); } } ctx.closePath(); } ctx.stroke(); // Highlight the next piece if (i === 0) { ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 2; ctx.strokeRect(queueX - 5, queueY - 5, pieceSize + 10, pieceSize + 10); } } }; // Draw rhythm guide const drawRhythmGuide = (ctx) => { const guideY = canvasRef.current.height - 80; const guideHeight = 40; const guideWidth = canvasRef.current.width - 40; const startX = 20; // Get audio data for visual effects const bands = frequencyBandsRef.current; const beatDetected = beatDetectedRef.current; // Draw guide background with audio reactivity const bassIntensity = bands.bass.average / 255; const midIntensity = bands.midrange.average / 255; // Create gradient that reacts to audio const bgGradient = ctx.createLinearGradient(startX, guideY, startX + guideWidth, guideY); bgGradient.addColorStop(0, `rgba(10, 10, ${30 + midIntensity * 50}, 1)`); bgGradient.addColorStop(0.5, `rgba(${10 + bassIntensity * 40}, 10, ${50 + midIntensity * 30}, 1)`); bgGradient.addColorStop(1, `rgba(10, 10, ${30 + midIntensity * 50}, 1)`); ctx.fillStyle = bgGradient; ctx.fillRect(startX, guideY, guideWidth, guideHeight); // Draw beat markers const now = audioElementRef.current ? audioElementRef.current.currentTime * 1000 : Date.now(); const visibleTimeWindow = 3000; // Show 3 seconds of beats for (let i = 0; i < beatTimingsRef.current.length; i++) { const beatTime = beatTimingsRef.current[i]; const timeDiff = beatTime - now; // Only show beats within our visible window if (timeDiff > -500 && timeDiff < visibleTimeWindow) { const beatX = startX + guideWidth * (1 - timeDiff / visibleTimeWindow); // Beat marker size and color based on proximity to current time let markerSize = 10; let alpha = 1; if (timeDiff < 0) { // Past beats fade out alpha = Math.max(0, 1 + timeDiff / 500); markerSize = 8; } else if (timeDiff < 200) { // Current beat is larger markerSize = 15; } // Draw beat marker with audio reactivity const hue = 280 + bassIntensity * 60; // Purple to pink const saturation = 100; const lightness = 50 + midIntensity * 30; ctx.fillStyle = `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`; ctx.beginPath(); ctx.arc(beatX, guideY + guideHeight / 2, markerSize, 0, Math.PI * 2); ctx.fill(); // Add glow on beat detection if (beatDetected && timeDiff >= -100 && timeDiff <= 100) { ctx.shadowColor = `hsla(${hue}, ${saturation}%, ${lightness}%, 0.8)`; ctx.shadowBlur = 15; ctx.beginPath(); ctx.arc(beatX, guideY + guideHeight / 2, markerSize + 5, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; } // Highlight perfect timing window if (timeDiff >= -100 && timeDiff <= 100) { ctx.strokeStyle = '#ffff00'; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(beatX, guideY + guideHeight / 2, markerSize + 5, 0, Math.PI * 2); ctx.stroke(); } } } // Draw playhead with audio reactivity const playheadWidth = 3 + bassIntensity * 2; const playheadHeight = guideHeight + 10 + midIntensity * 10; // Create gradient for playhead const playheadGradient = ctx.createLinearGradient(0, guideY - 5, 0, guideY + playheadHeight); playheadGradient.addColorStop(0, '#ff00ff'); playheadGradient.addColorStop(0.5, '#ffffff'); playheadGradient.addColorStop(1, '#00ffff'); ctx.fillStyle = playheadGradient; ctx.fillRect(startX + guideWidth / 4, guideY - 5, playheadWidth, playheadHeight); // Add glow to playhead if (bassIntensity > 0.6 || beatDetected) { ctx.shadowColor = '#ff00ff'; ctx.shadowBlur = 10 + bassIntensity * 10; ctx.fillRect(startX + guideWidth / 4, guideY - 5, playheadWidth, playheadHeight); ctx.shadowBlur = 0; } // Draw labels ctx.font = '14px Inter, sans-serif'; ctx.fillStyle = '#ffffff'; ctx.fillText('Place here', startX + guideWidth / 4 - 30, guideY - 10); }; // Check for beats and handle timing const checkBeats = () => { if (!audioElementRef.current) return; const now = audioElementRef.current.currentTime * 1000; const currentBeatIndex = currentBeatIndexRef.current; if (currentBeatIndex < beatTimingsRef.current.length) { const nextBeatTime = beatTimingsRef.current[currentBeatIndex]; const timeDiff = nextBeatTime - now; // If we've passed a beat, move to the next one if (timeDiff < -200) { currentBeatIndexRef.current++; // If player missed the beat, break combo if (combo > 0) { setCombo(0); } } } }; // Handle player input (placing a road piece) const handlePlaceRoad = (x, y) => { if (gameState !== 'playing' || !upcomingPiecesRef.current.length) return; // Check if the spot is empty if (gridRef.current[y][x]) return; const now = audioElementRef.current ? audioElementRef.current.currentTime * 1000 : Date.now(); let onBeat = false; let perfectTiming = false; // Check if placement is on beat for (const beatTime of beatTimingsRef.current) { const timeDiff = Math.abs(now - beatTime); if (timeDiff < 200) { // Within 200ms of a beat onBeat = true; if (timeDiff < 50) { // Perfect timing within 50ms perfectTiming = true; } break; } } if (onBeat) { // Place the road piece const piece = upcomingPiecesRef.current.shift(); gridRef.current[y][x] = piece; // Generate a new piece for the queue generateUpcomingPieces(); // Update score and combo let pointsEarned = piece.points; if (perfectTiming) { pointsEarned *= 2; setCombo(combo + 2); } else { setCombo(combo + 1); } // Apply combo multiplier pointsEarned *= (1 + combo * 0.1); // Apply special piece bonus if (piece.type === 'special' && piece.bonus === 'doublePoints') { pointsEarned *= 2; } setScore(score + Math.floor(pointsEarned)); // Play placement sound playPlacementSound(perfectTiming); // Visual feedback createPlacementEffect(x, y); } else { // Break combo if not on beat setCombo(0); // Play error sound playErrorSound(); } }; // Play placement sound const playPlacementSound = (perfect) => { // In a real implementation, this would play actual sounds console.log(`Playing ${perfect ? 'perfect' : 'good'} placement sound`); }; // Play error sound const playErrorSound = () => { console.log('Playing error sound'); }; // Create visual effect for placement const createPlacementEffect = (x, y) => { // In a real implementation, this would create particles or other visual effects console.log(`Creating placement effect at ${x}, ${y}`); }; // Handle canvas click/touch const handleCanvasClick = (e) => { if (gameState !== 'playing') return; const canvas = canvasRef.current; const rect = canvas.getBoundingClientRect(); const clickX = e.clientX - rect.left; const clickY = e.clientY - rect.top; // Convert screen coordinates to isometric grid coordinates const tileWidth = 40; const tileHeight = 20; const offsetX = canvas.width / 2; const offsetY = 150; // This is a simplified conversion - a real implementation would be more precise const gridX = Math.floor((clickX - offsetX) / tileWidth + (clickY - offsetY) / tileHeight); const gridY = Math.floor((clickY - offsetY) / tileHeight - (clickX - offsetX) / tileWidth); if (gridX >= 0 && gridX < 10 && gridY >= 0 && gridY < 10) { handlePlaceRoad(gridX, gridY); } }; // Start the game const startGame = () => { setGameState('playing'); setScore(0); setCombo(0); // Reset grid gridRef.current = Array(10).fill().map(() => Array(10).fill(null)); // Generate initial upcoming pieces upcomingPiecesRef.current = []; generateUpcomingPieces(); // Reset audio analysis data frequencyBandsRef.current = { bass: { sum: 0, average: 0, peak: 0 }, midrange: { sum: 0, average: 0, peak: 0 }, treble: { sum: 0, average: 0, peak: 0 } }; // Start music if (audioElementRef.current) { audioElementRef.current.currentTime = 0; audioElementRef.current.play(); // Resume audio context if it was suspended if (audioContextRef.current && audioContextRef.current.state === 'suspended') { audioContextRef.current.resume(); } } }; // Pause the game const pauseGame = () => { setGameState('paused'); if (audioElementRef.current) { audioElementRef.current.pause(); } }; // Resume the game const resumeGame = () => { setGameState('playing'); if (audioElementRef.current) { audioElementRef.current.play(); // Resume audio context if it was suspended if (audioContextRef.current && audioContextRef.current.state === 'suspended') { audioContextRef.current.resume(); } } }; // End the game const endGame = () => { setGameState('gameOver'); if (score > highScore) { setHighScore(score); } if (audioElementRef.current) { audioElementRef.current.pause(); } }; // Toggle mute const toggleMute = () => { setIsMuted(!isMuted); if (audioElementRef.current) { audioElementRef.current.muted = !isMuted; } }; // Render different game screens const renderGameScreen = () => { switch (gameState) { case 'menu': return renderMenuScreen(); case 'playing': case 'paused': return renderPlayingScreen(); case 'gameOver': return renderGameOverScreen(); default: return renderMenuScreen(); } }; // Render menu screen const renderMenuScreen = () => { return (
Build roads in sync with the beat in this neon-drenched rhythm game!
Place road pieces when the beat hits the marker for maximum points and combos.
Adjust if the beat timing feels off
Build roads in sync with the music to earn points and create combos.
Click or tap on the grid to place road pieces. Time your placements with the beat!