创建一个简单的记忆翻牌游戏

创建一个简单的记忆翻牌游戏,包含以下功能:

  1. 6对卡片随机排列
  2. 点击翻牌效果和匹配音效
  3. 键盘操作支持(Tab聚焦/Enter选择)
  4. 屏幕阅读器友好提示
  5. 胜利动画和音效
XML 复制代码
import { useState, useEffect, useCallback } from 'react';
import { Volume2, VolumeX, Trophy } from 'lucide-react';

const MemoryGame = () => {
  const [cards, setCards] = useState([]);
  const [flipped, setFlipped] = useState([]);
  const [matched, setMatched] = useState([]);
  const [moves, setMoves] = useState(0);
  const [isSoundOn, setIsSoundOn] = useState(true);
  const [gameWon, setGameWon] = useState(false);

  // 初始化卡片数据
  const initGame = useCallback(() => {
    const emojis = ['🐶', '🐱', '🐭', '🐹', '🐰', '🦊'];
    const cardPairs = [...emojis, ...emojis];
    const shuffled = cardPairs
      .sort(() => Math.random() - 0.5)
      .map((emoji, index) => ({ id: index, emoji, flipped: false }));
    
    setCards(shuffled);
    setFlipped([]);
    setMatched([]);
    setMoves(0);
    setGameWon(false);
  }, []);

  useEffect(() => {
    initGame();
  }, [initGame]);

  // 播放音效
  const playSound = (soundType) => {
    if (!isSoundOn) return;
    
    const audioContext = new (window.AudioContext || window.webkitAudioContext)();
    const oscillator = audioContext.createOscillator();
    const gainNode = audioContext.createGain();
    
    oscillator.connect(gainNode);
    gainNode.connect(audioContext.destination);
    
    if (soundType === 'flip') {
      oscillator.type = 'sine';
      oscillator.frequency.setValueAtTime(440, audioContext.currentTime);
      gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
      gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
    } else if (soundType === 'match') {
      oscillator.type = 'triangle';
      oscillator.frequency.setValueAtTime(660, audioContext.currentTime);
      gainNode.gain.setValueAtTime(0.2, audioContext.currentTime);
      gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.5);
    } else if (soundType === 'win') {
      oscillator.type = 'sawtooth';
      oscillator.frequency.setValueAtTime(880, audioContext.currentTime);
      gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
      gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 1);
    }
    
    oscillator.start();
    oscillator.stop(audioContext.currentTime + (soundType === 'win' ? 1 : 0.5));
  };

  // 处理卡片点击
  const handleCardClick = (id) => {
    if (flipped.length >= 2 || flipped.includes(id) || matched.includes(id) || gameWon) {
      return;
    }
    
    const newFlipped = [...flipped, id];
    setFlipped(newFlipped);
    playSound('flip');
    
    if (newFlipped.length === 2) {
      setMoves(moves + 1);
      const [firstId, secondId] = newFlipped;
      const firstCard = cards.find(card => card.id === firstId);
      const secondCard = cards.find(card => card.id === secondId);
      
      if (firstCard.emoji === secondCard.emoji) {
        setMatched([...matched, firstId, secondId]);
        playSound('match');
        
        // 检查是否胜利
        if (matched.length + 2 === cards.length) {
          setTimeout(() => {
            setGameWon(true);
            playSound('win');
          }, 500);
        }
      } else {
        setTimeout(() => {
          setFlipped([]);
        }, 1000);
      }
    }
  };

  // 键盘导航
  useEffect(() => {
    const handleKeyDown = (e) => {
      if (gameWon) return;
      
      const focusableCards = document.querySelectorAll('.card:not([aria-disabled="true"])');
      const currentFocus = document.activeElement;
      const currentIndex = Array.from(focusableCards).indexOf(currentFocus);
      
      if (e.key === 'Tab') {
        // 默认Tab行为已由浏览器处理
      } else if (e.key === 'Enter' && currentFocus.classList.contains('card')) {
        const id = parseInt(currentFocus.getAttribute('data-id'));
        handleCardClick(id);
      } else if (e.key === 'ArrowRight') {
        e.preventDefault();
        if (currentIndex < focusableCards.length - 1) {
          focusableCards[currentIndex + 1].focus();
        } else {
          focusableCards[0].focus();
        }
      } else if (e.key === 'ArrowLeft') {
        e.preventDefault();
        if (currentIndex > 0) {
          focusableCards[currentIndex - 1].focus();
        } else {
          focusableCards[focusableCards.length - 1].focus();
        }
      }
    };

    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [flipped, matched, gameWon, cards]);

  return (
    <div className="min-h-screen bg-gradient-to-br from-indigo-100 to-purple-100 flex flex-col items-center justify-center p-4">
      <div className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-2xl">
        <div className="flex justify-between items-center mb-6">
          <h1 className="text-3xl font-bold text-indigo-800">记忆翻牌游戏</h1>
          
          <div className="flex items-center gap-4">
            <div className="bg-indigo-100 px-4 py-2 rounded-full flex items-center gap-2">
              <span className="font-medium">步数:</span>
              <span className="font-bold">{moves}</span>
            </div>
            
            <button
              onClick={() => setIsSoundOn(!isSoundOn)}
              className={`p-2 rounded-full ${isSoundOn ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-600'}`}
              aria-label={isSoundOn ? '关闭声音' : '开启声音'}
            >
              {isSoundOn ? <Volume2 size={20} /> : <VolumeX size={20} />}
            </button>
          </div>
        </div>
        
        {gameWon && (
          <div className="mb-6 p-4 bg-gradient-to-r from-green-100 to-emerald-100 rounded-xl border border-green-200 flex items-center justify-center gap-2 animate-bounce">
            <Trophy className="text-yellow-500" size={24} />
            <span className="text-xl font-bold text-green-800">恭喜获胜!</span>
          </div>
        )}
        
        <div className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
          {cards.map((card) => {
            const isFlipped = flipped.includes(card.id) || matched.includes(card.id);
            const isDisabled = matched.includes(card.id) || gameWon;
            
            return (
              <button
                key={card.id}
                data-id={card.id}
                onClick={() => handleCardClick(card.id)}
                disabled={isDisabled}
                className={`card relative aspect-square rounded-xl transition-all duration-300 transform ${
                  isFlipped 
                    ? 'bg-white rotate-y-180 scale-105 shadow-md' 
                    : 'bg-gradient-to-br from-indigo-500 to-purple-600 rotate-y-0 hover:scale-105'
                } ${isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
                tabIndex={isDisabled ? -1 : 0}
                aria-disabled={isDisabled}
                aria-label={isFlipped ? `已翻开的卡片,图案是${card.emoji}` : '未翻开的卡片'}
              >
                <div className={`absolute inset-0 flex items-center justify-center transition-transform duration-500 transform-style-preserve-3d ${
                  isFlipped ? 'rotate-y-180' : ''
                }`}>
                  <div className="back-face absolute inset-0 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center">
                    <span className="text-white text-4xl">?</span>
                  </div>
                  <div className="front-face absolute inset-0 bg-white rounded-xl flex items-center justify-center rotate-y-180">
                    <span className="text-4xl">{card.emoji}</span>
                  </div>
                </div>
              </button>
            );
          })}
        </div>
        
        <div className="mt-8 text-center">
          <button
            onClick={initGame}
            className="px-6 py-3 bg-gradient-to-r from-indigo-500 to-purple-600 text-white rounded-full font-medium hover:from-indigo-600 hover:to-purple-700 transition-all shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
          >
            重新开始
          </button>
        
          <div className="mt-4 text-sm text-gray-600">
            <p className="mb-1">操作说明:</p>
            <ul className="list-disc list-inside text-left">
              <li>使用鼠标点击或键盘方向键导航</li>
              <li>按Enter键翻开卡片</li>
              <li>找到所有匹配的卡片对</li>
            </ul>
          </div>
        </div>
      </div>
      
      <style jsx>{`
        .transform-style-preserve-3d {
          transform-style: preserve-3d;
        }
        .rotate-y-0 {
          transform: rotateY(0deg);
        }
        .rotate-y-180 {
          transform: rotateY(180deg);
        }
        .card:focus-visible {
          outline: 3px solid #4f46e5;
          outline-offset: 2px;
        }
      `}</style>
    </div>
  );
};

export default MemoryGame;
相关推荐
ShyanZh6 小时前
【GTA】无法启动游戏排查:RockstarLauncher-C0000005与IDA环境冲突排查
游戏·gta
李航198313 小时前
虚拟盖房子,在游戏中学习房子的建造过程
人工智能·学习·游戏·计算机视觉·ai编程
充值内卷15 小时前
C++单文件迷宫游戏:TauriCPP 开发全记录
开发语言·c++·游戏
郝学胜-神的一滴17 小时前
游戏设计:原型与开发 12:概率和反馈与数值平衡实战
人工智能·游戏·游戏程序·游戏策划·游戏设计
Solange__1 天前
影视预演怎么用AI生成3D场景?从分镜、Blockout到镜头交付的完整流程
人工智能·游戏·3d·ai·自然语言处理·aigc·游戏策划
郝学胜-神的一滴1 天前
游戏设计:原型与开发 11:用概率与电子表格搞定骰子系统
程序人生·游戏·游戏策划·1024程序员节·游戏设计
treesforest1 天前
游戏账号安全风险分层方案:IP信号分层与人机复核实践
tcp/ip·安全·游戏·1024程序员节
Raspberry_Pi_官方账号1 天前
隆重推出《Raspberry Pi 复古游戏指南》
游戏·树莓派·diy·raspberry pi
Oiiouui1 天前
针对Godot-GridGame(网格游戏)的DI注入+RefCounted数据驱动信号流架构设计的研究(全程开源)
游戏·游戏引擎·godot·1024程序员节
极验2 天前
全能注册机之后,游戏风控的战场变了
游戏