diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 03a82c7..569d6d4 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { Settings, Monitor, Volume2, Cpu, X, Contrast, Binary } from 'lucide-react'; -import { useSettings } from '@/contexts/SettingsContext'; +import { Settings, Monitor, Volume2, Cpu, X, Contrast, Binary, Smartphone } from 'lucide-react'; +import { useSettings, MobileControlMode } from '@/contexts/SettingsContext'; import { useAchievements } from '@/contexts/AchievementsContext'; import CryptoConsentModal from './CryptoConsentModal'; @@ -13,7 +13,7 @@ interface SettingsPanelProps { const SettingsPanel = ({ onToggleTheme, isRedTheme }: SettingsPanelProps) => { const [isOpen, setIsOpen] = useState(false); const [showCryptoModal, setShowCryptoModal] = useState(false); - const { crtEnabled, setCrtEnabled, matrixEnabled, setMatrixEnabled, soundEnabled, setSoundEnabled, cryptoConsent, playSound } = useSettings(); + const { crtEnabled, setCrtEnabled, matrixEnabled, setMatrixEnabled, soundEnabled, setSoundEnabled, cryptoConsent, playSound, mobileControlMode, setMobileControlMode } = useSettings(); const { unlockAchievement } = useAchievements(); const handleToggle = (setter: (value: boolean) => void, currentValue: boolean, achievementId?: string) => { @@ -22,6 +22,11 @@ const SettingsPanel = ({ onToggleTheme, isRedTheme }: SettingsPanelProps) => { if (achievementId) unlockAchievement(achievementId); }; + const handleMobileControlToggle = () => { + playSound('click'); + setMobileControlMode(mobileControlMode === 'buttons' ? 'swipe' : 'buttons'); + }; + return ( <> + + {/* Mobile Controls Mode */} +
+
+ + Mobile Controls +
+ +
diff --git a/src/contexts/SettingsContext.tsx b/src/contexts/SettingsContext.tsx index 2f71346..f604b94 100644 --- a/src/contexts/SettingsContext.tsx +++ b/src/contexts/SettingsContext.tsx @@ -3,6 +3,8 @@ import { useAudioAnalyzer } from './AudioAnalyzerContext'; type SoundType = 'click' | 'beep' | 'hover' | 'boot' | 'success' | 'error'; +type MobileControlMode = 'swipe' | 'buttons'; + interface SettingsContextType { crtEnabled: boolean; setCrtEnabled: (enabled: boolean) => void; @@ -26,6 +28,8 @@ interface SettingsContextType { disableAudio: () => void; userInteracted: boolean; setUserInteracted: (interacted: boolean) => void; + mobileControlMode: MobileControlMode; + setMobileControlMode: (mode: MobileControlMode) => void; } const SettingsContext = createContext(undefined); @@ -66,6 +70,16 @@ export const SettingsProvider = ({ children }: { children: ReactNode }) => { sessionStorage.setItem('cryptoConsentPrompted', 'true'); }; + const [mobileControlMode, setMobileControlModeState] = useState(() => { + const saved = localStorage.getItem('mobileControlMode'); + return (saved as MobileControlMode) || 'buttons'; + }); + + const setMobileControlMode = (mode: MobileControlMode) => { + setMobileControlModeState(mode); + localStorage.setItem('mobileControlMode', mode); + }; + const [audioBlocked, setAudioBlocked] = useState(false); const [showAudioOverlay, setShowAudioOverlay] = useState(false); const [userInteracted, setUserInteracted] = useState(false); @@ -307,6 +321,8 @@ export const SettingsProvider = ({ children }: { children: ReactNode }) => { disableAudio, userInteracted, setUserInteracted, + mobileControlMode, + setMobileControlMode, }} > {children} @@ -314,6 +330,8 @@ export const SettingsProvider = ({ children }: { children: ReactNode }) => { ); }; +export type { MobileControlMode }; + export const useSettings = () => { const context = useContext(SettingsContext); if (context === undefined) { diff --git a/src/pages/AIChat.tsx b/src/pages/AIChat.tsx index 0822ebb..04d19d5 100644 --- a/src/pages/AIChat.tsx +++ b/src/pages/AIChat.tsx @@ -382,7 +382,7 @@ const AIChat = () => { sendMessage(); } }; - const content = ( + return ( { ); - - return content; }; export default AIChat; diff --git a/src/pages/Breakout.tsx b/src/pages/Breakout.tsx index 0027b74..78c3d15 100644 --- a/src/pages/Breakout.tsx +++ b/src/pages/Breakout.tsx @@ -24,8 +24,32 @@ interface Brick { height: number; alive: boolean; color: string; + hasPowerUp?: PowerUpType; } +type PowerUpType = 'wide' | 'multi' | 'slow' | 'life'; + +interface PowerUp { + x: number; + y: number; + type: PowerUpType; + active: boolean; +} + +const POWER_UP_COLORS: Record = { + wide: 'hsl(200 80% 60%)', + multi: 'hsl(280 80% 60%)', + slow: 'hsl(45 80% 60%)', + life: 'hsl(0 80% 60%)', +}; + +const POWER_UP_LABELS: Record = { + wide: 'W', + multi: 'M', + slow: 'S', + life: '+', +}; + const Breakout = () => { const { playSound } = useSettings(); const { checkGameScoreAchievements, unlockMaxScore } = useAchievements(); @@ -40,6 +64,12 @@ const Breakout = () => { const [showGlitchCrash, setShowGlitchCrash] = useState(false); const [isTwoPlayer, setIsTwoPlayer] = useState(false); + // Power-up state + const [activePowerUps, setActivePowerUps] = useState<{ type: PowerUpType; endTime: number }[]>([]); + const powerUpsRef = useRef([]); + const extraBallsRef = useRef<{ x: number; y: number; dx: number; dy: number }[]>([]); + const basePaddleWidth = useRef(90); + // P2 state const [score2, setScore2] = useState(0); const [lives2, setLives2] = useState(3); @@ -91,7 +121,11 @@ const Breakout = () => { useEffect(() => { setCanvasSize(getCanvasSize()); }, [isFullscreen, getCanvasSize, isTwoPlayer]); - const paddleWidth = isMobile ? 70 : (isTwoPlayer ? 70 : (isFullscreen ? 100 : 90)); + // Calculate paddle width based on active power-ups + const hasWidePaddle = activePowerUps.some(p => p.type === 'wide' && p.endTime > Date.now()); + const hasSlowBall = activePowerUps.some(p => p.type === 'slow' && p.endTime > Date.now()); + basePaddleWidth.current = isMobile ? 70 : (isTwoPlayer ? 70 : (isFullscreen ? 100 : 90)); + const paddleWidth = hasWidePaddle ? basePaddleWidth.current * 1.5 : basePaddleWidth.current; const ballSize = isMobile ? 10 : (isTwoPlayer ? 10 : (isFullscreen ? 14 : 12)); useEffect(() => { @@ -126,9 +160,23 @@ const Breakout = () => { const bricks: Brick[] = []; const brickWidth = (width - (BRICK_COLS + 1) * BRICK_GAP) / BRICK_COLS; const colors = ['hsl(0 70% 50%)', 'hsl(30 70% 50%)', 'hsl(60 70% 50%)', 'hsl(120 70% 50%)', 'hsl(200 70% 50%)', 'hsl(280 70% 50%)']; + const powerUpTypes: PowerUpType[] = ['wide', 'multi', 'slow', 'life']; + for (let row = 0; row < BRICK_ROWS; row++) { for (let col = 0; col < BRICK_COLS; col++) { - bricks.push({ x: BRICK_GAP + col * (brickWidth + BRICK_GAP), y: 60 + row * (BRICK_HEIGHT + BRICK_GAP), width: brickWidth, height: BRICK_HEIGHT, alive: true, color: colors[row % colors.length] }); + // ~15% chance of having a power-up + const hasPowerUp = Math.random() < 0.15; + const powerUpType = hasPowerUp ? powerUpTypes[Math.floor(Math.random() * powerUpTypes.length)] : undefined; + + bricks.push({ + x: BRICK_GAP + col * (brickWidth + BRICK_GAP), + y: 60 + row * (BRICK_HEIGHT + BRICK_GAP), + width: brickWidth, + height: BRICK_HEIGHT, + alive: true, + color: colors[row % colors.length], + hasPowerUp: powerUpType + }); } } return bricks; @@ -143,6 +191,9 @@ const Breakout = () => { const startGame = (twoPlayer: boolean) => { setIsTwoPlayer(twoPlayer); setScore(0); setLevel(1); setLives(3); setGameOver(false); setIsPaused(false); setGameStarted(true); + setActivePowerUps([]); + powerUpsRef.current = []; + extraBallsRef.current = []; p1DeadRef.current = false; p2DeadRef.current = false; @@ -278,6 +329,17 @@ const Breakout = () => { allDestroyed = false; if (ball.x + ballSize / 2 >= brick.x && ball.x - ballSize / 2 <= brick.x + brick.width && ball.y + ballSize / 2 >= brick.y && ball.y - ballSize / 2 <= brick.y + brick.height) { brick.alive = false; ball.dy = -ball.dy; + + // Spawn power-up if brick had one (P1 only for simplicity) + if (brick.hasPowerUp && playerNum === 1) { + powerUpsRef.current.push({ + x: brick.x + brick.width / 2, + y: brick.y + brick.height, + type: brick.hasPowerUp, + active: true + }); + } + setScoreParam(prev => { const ns = Math.min(prev + 10, MAX_SCORE); if (ns >= MAX_SCORE && playerNum === 1) setShowGlitchCrash(true); @@ -287,6 +349,52 @@ const Breakout = () => { playSound('success'); break; } } + + // Update power-ups (P1 only) + if (playerNum === 1) { + const paddleY = canvasSize.height - 30; + for (const pu of powerUpsRef.current) { + if (!pu.active) continue; + pu.y += 2; // Fall speed + + // Check paddle collision + if (pu.y >= paddleY && pu.y <= paddleY + PADDLE_HEIGHT && pu.x >= paddleRefParam.current && pu.x <= paddleRefParam.current + paddleWidth) { + pu.active = false; + playSound('success'); + + if (pu.type === 'life') { + setLivesParam(prev => Math.min(prev + 1, 5)); + } else if (pu.type === 'multi') { + // Add extra ball + const newBall = { x: ball.x, y: ball.y, dx: -ball.dx, dy: ball.dy }; + extraBallsRef.current.push(newBall); + } else { + // Wide or Slow - timed power-up (8 seconds) + setActivePowerUps(prev => [...prev.filter(p => p.type !== pu.type), { type: pu.type, endTime: Date.now() + 8000 }]); + } + } + + // Remove if off screen + if (pu.y > canvasSize.height) pu.active = false; + } + powerUpsRef.current = powerUpsRef.current.filter(pu => pu.active); + + // Update extra balls + for (const eb of extraBallsRef.current) { + const speedMod = hasSlowBall ? 0.6 : 1; + eb.x += eb.dx * speedMod; eb.y += eb.dy * speedMod; + if (eb.x <= ballSize / 2 || eb.x >= canvasSize.width - ballSize / 2) eb.dx = -eb.dx; + if (eb.y <= ballSize / 2) eb.dy = -eb.dy; + if (eb.y + ballSize / 2 >= paddleY && eb.y - ballSize / 2 <= paddleY + PADDLE_HEIGHT && eb.x >= paddleRefParam.current && eb.x <= paddleRefParam.current + paddleWidth) { + const hitPos = (eb.x - paddleRefParam.current) / paddleWidth; + const angle = (hitPos - 0.5) * Math.PI * 0.7; + const speed = Math.sqrt(eb.dx * eb.dx + eb.dy * eb.dy); + eb.dx = Math.sin(angle) * speed; eb.dy = -Math.abs(Math.cos(angle) * speed); + eb.y = paddleY - ballSize / 2; + } + } + extraBallsRef.current = extraBallsRef.current.filter(eb => eb.y < canvasSize.height); + } if (allDestroyed && bricksRefParam.current.length > 0 && !deadRef.current) nextLevel(playerNum); @@ -296,6 +404,25 @@ const Breakout = () => { if (!brick.alive) continue; ctx.fillStyle = brick.color; ctx.fillRect(brick.x, brick.y, brick.width, brick.height); ctx.strokeStyle = primaryColor; ctx.globalAlpha = 0.5; ctx.strokeRect(brick.x, brick.y, brick.width, brick.height); ctx.globalAlpha = 1; + // Draw power-up indicator + if (brick.hasPowerUp) { + ctx.fillStyle = POWER_UP_COLORS[brick.hasPowerUp]; + ctx.font = '10px monospace'; + ctx.textAlign = 'center'; + ctx.fillText(POWER_UP_LABELS[brick.hasPowerUp], brick.x + brick.width / 2, brick.y + brick.height / 2 + 3); + } + } + + // Draw falling power-ups + if (playerNum === 1) { + for (const pu of powerUpsRef.current) { + if (!pu.active) continue; + ctx.fillStyle = POWER_UP_COLORS[pu.type]; + ctx.shadowColor = POWER_UP_COLORS[pu.type]; ctx.shadowBlur = 8; + ctx.beginPath(); ctx.arc(pu.x, pu.y, 8, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#000'; ctx.font = 'bold 10px monospace'; ctx.textAlign = 'center'; + ctx.fillText(POWER_UP_LABELS[pu.type], pu.x, pu.y + 4); ctx.shadowBlur = 0; + } } ctx.fillStyle = primaryColor; ctx.shadowColor = primaryColor; ctx.shadowBlur = 10; diff --git a/src/pages/Pacman.tsx b/src/pages/Pacman.tsx index b200b0e..cc9ddda 100644 --- a/src/pages/Pacman.tsx +++ b/src/pages/Pacman.tsx @@ -8,6 +8,7 @@ import { Maximize2, Minimize2 } from 'lucide-react'; import { useBrowserFullscreen } from '@/hooks/useGameDimensions'; import { MobileControlsDock, MobileControlsSpacer } from '@/components/MobileControlsDock'; import { useSwipeControls } from '@/hooks/useSwipeControls'; +import GameTouchButton from '@/components/GameTouchButton'; const GRID_WIDTH = 21; const GRID_HEIGHT = 21; @@ -44,7 +45,7 @@ const MAZE_TEMPLATE = [ ]; const Pacman = () => { - const { playSound } = useSettings(); + const { playSound, mobileControlMode } = useSettings(); const { checkGameScoreAchievements, unlockMaxScore } = useAchievements(); // Game mode selection @@ -115,9 +116,11 @@ const Pacman = () => { onTap: () => { if (gameStarted && !gameOver && !gameComplete) setIsPaused(p => !p); }, - enabled: isMobile && gameStarted && !gameOver && !gameComplete, + enabled: isMobile && gameStarted && !gameOver && !gameComplete && mobileControlMode === 'swipe', }); + const useButtonControls = isMobile && mobileControlMode === 'buttons'; + useEffect(() => { const handleResize = () => setCellSize(getCellSize()); window.addEventListener('resize', handleResize); @@ -766,15 +769,27 @@ const Pacman = () => {
) : gameStarted && !gameOver && !gameComplete ? ( -
-

- SWIPE TO MOVE
- TAP TO PAUSE -

-
+ useButtonControls ? ( +
+ +
+ + + +
+ +
+ ) : ( +
+

+ SWIPE TO MOVE
+ TAP TO PAUSE +

+
+ ) ) : ( +
+ + + +
+ + + ) : ( +
+

+ SWIPE TO MOVE
+ TAP TO PAUSE +

+
+ ) ) : ( + + +
+ + +
+ + ) : ( +
+

+ ← → SWIPE TO MOVE
+ ↓ SOFT DROP · FAST ↓ HARD DROP
+ ↑ ROTATE · TAP TO PAUSE +

+
+ ) ) : ( )}