You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React宝可梦卡牌翻转优化:实现180°时隐藏图片需求

宝可梦卡牌翻转动画优化方案

问题背景

我开发了一个展示宝可梦卡牌的小型React应用,Card组件通过card-front和card-back两个className对应的div实现卡牌正反面。点击卡牌时会执行从0%→50% rotateY(180deg)→100% rotateY(360deg)的旋转动画,但当前图片全程可见。期望实现以下序列:

  • 点击卡牌后从0°翻至180°
  • 180°翻转状态下仅显示纯色背景,无图片
  • 360°(动画结束)时显示新图片

目前点击后图片直接切换,翻转过程中图片始终显示。曾尝试用setTimeout包裹if (card.clicked)…逻辑,延迟时长设为动画总时长(0.5s)的一半(0.25s),但该方案较为粗糙,还导致页面中部分卡牌重复翻转,希望在React中流畅实现该序列逻辑。


核心思路

利用CSS动画关键帧控制图片显示状态,结合React状态管理同步动画与图片切换,替代粗糙的setTimeout方案:

  • 为卡牌正面图片添加同步动画,在翻转到180°时隐藏,360°时恢复显示
  • 限制动画过程中的重复点击,避免异常翻转
  • 动画结束后再执行洗牌和状态更新,确保360°翻转完成后才展示新图片

代码修改步骤

1. 优化CSS动画与卡牌布局

修改原有动画,新增图片显隐动画,并补充3D透视效果增强真实感:

/* Card */
.card {
  display: flex;
  justify-content: center;
  align-items: center;
  background-color: var(--poke-brown);
  border-radius: 8px;
  padding: 0.5em;
  margin: 0 auto;
  width: 150px;
  height: 150px; /* 固定高度保证翻转对齐 */
  box-shadow: 0 0 2em rgba(0, 0, 0, 0.25);
  cursor: pointer;
}

.card:hover {
  filter: drop-shadow(0 0 2em var(--light-bg));
}

.card-inner {
  position: relative;
  width: 100%;
  height: 100%;
  transform-style: preserve-3d; /* 开启3D透视 */
}

.card-front, .card-back {
  position: absolute;
  width: 100%;
  height: 100%;
  backface-visibility: hidden; /* 隐藏背面元素 */
  display: flex;
  justify-content: center;
  align-items: center;
  border-radius: 8px;
}

.card-back {
  background-color: var(--poke-brown); /* 和卡牌背景一致的纯色 */
  transform: rotateY(180deg);
}

.card-img {
  max-width: 125px;
  height: 125px;
}

@keyframes shuffle {
  0% {
    transform: rotateY(0deg);
  }
  50% {
    transform: rotateY(180deg);
  }
  100% {
    transform: rotateY(360deg);
  }
}

@keyframes hideImage {
  0%, 100% {
    opacity: 1;
    visibility: visible;
  }
  50% {
    opacity: 0;
    visibility: hidden;
  }
}

.card-shuffle-animation {
  animation: shuffle 0.5s ease forwards;
}

.card-shuffle-animation .card-img {
  animation: hideImage 0.5s ease forwards;
}

2. 调整GamePage状态逻辑

添加动画状态控制,等待动画结束后再执行洗牌和状态更新:

import { useState, useEffect, useRef } from 'react';
import { Scoreboard } from './Scoreboard';
import { Card } from './Card';

function shuffleArray(array) {
  let newArray = [...array];
  let currentIndex = newArray.length, randomIndex;
  while (currentIndex !== 0) {
    randomIndex = Math.floor(Math.random() * currentIndex);
    currentIndex--;
    [newArray[currentIndex], newArray[randomIndex]] = [newArray[randomIndex], newArray[currentIndex]];
  }
  return newArray;
}

function GamePage() {
  const [cards, setCards] = useState([]);
  const [currentScore, setCurrentScore] = useState(0);
  const [highScore, setHighScore] = useState(0);
  const [isAnimating, setIsAnimating] = useState(false);
  const activeAnimations = useRef({});

  useEffect(() => {
    fetch('https://pokeapi.co/api/v2/pokemon?limit=12')
      .then(response => response.json())
      .then(data => {
        const cards = data.results.map((pokemon, index) => ({
          id: index,
          image: `https://github.com/sashafirsov/pokeapi-sprites/blob/master/sprites/pokemon/other/dream-world/${index + 1}.svg?raw=true`,
          clicked: false
        }));
        setCards(cards);
      });
  }, []);

  const handleAnimationEnd = (id) => {
    delete activeAnimations.current[id];
    if (Object.keys(activeAnimations.current).length === 0) {
      setIsAnimating(false);
    }
  };

  function handleCardClick(id) {
    if (isAnimating) return;
    setIsAnimating(true);
    activeAnimations.current[id] = true;

    const card = cards.find(card => card.id === id);
    const shuffledCards = shuffleArray([...cards]);

    // 等待动画完成后更新状态
    setTimeout(() => {
      if (card.clicked) {
        setCards(shuffledCards.map(card => ({ ...card, clicked: false })));
        setCurrentScore(0);
      } else {
        const newScore = currentScore + 1;
        setCards(shuffledCards.map(card => 
          card.id === id ? { ...card, clicked: true } : card
        ));
        setCurrentScore(newScore);
        if (newScore > highScore) setHighScore(newScore);
      }
      setIsAnimating(false);
    }, 500);
  }

  return (
    <>
      <h1>Let&apos;s Play</h1>
      <Scoreboard currentScore={currentScore} highScore={highScore} />
      <div className='game-board'>
        {cards.map(card => (
          <Card
            key={card.id}
            image={card.image}
            onClick={() => handleCardClick(card.id)}
            className={activeAnimations.current[card.id] ? 'card-shuffle-animation' : ''}
            onAnimationEnd={() => handleAnimationEnd(card.id)}
          />
        ))}
      </div>
    </>
  );
}

export { GamePage };

3. 更新Card组件支持动画结束事件

import PropTypes from 'prop-types';

function Card({ image, onClick, className, onAnimationEnd }) {
  return (
    <div className={`card ${className}`} onClick={onClick} onAnimationEnd={onAnimationEnd}>
      <div className='card-inner'>
        <div className='card-front'>
          <img src={image} className='card-img' alt='Pokemon' />
        </div>
        <div className='card-back'></div>
      </div>
    </div>
  );
}

Card.propTypes = {
  image: PropTypes.string.isRequired,
  onClick: PropTypes.func.isRequired,
  className: PropTypes.string,
  onAnimationEnd: PropTypes.func.isRequired,
};

Card.defaultProps = {
  className: ''
};

export { Card };

方案说明

  • 通过hideImage动画在翻转到180°时隐藏图片,实现中间阶段仅显示纯色背景的需求
  • 使用isAnimating状态防止动画过程中重复点击,解决卡牌重复翻转问题
  • 动画结束后再执行洗牌和状态更新,确保360°翻转完成后才展示新图片
  • 移除原有的animationTrigger作为key的逻辑,改用动画状态控制className,减少不必要的组件卸载重渲染

内容的提问来源于stack exchange,提问作者JackJack

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.05 03:18:13