通过建立一个打地鼠游戏开始使用React

463 阅读11分钟

自从~v0.12发布以来,我一直在用React工作。(2014年!哇,时间都去哪儿了?)它已经改变了很多。我记得一路上的某些 "Aha "时刻。有一件事一直保持着,就是使用它的心态。我们以不同的方式思考问题,而不是直接用DOM工作。

对我来说,我的学习风格是尽可能快地启动和运行一些东西。然后,只要有必要,我就会去探索文档中更深的领域和包括的一切。在实践中学习,享受乐趣,并推动事情的发展。

目标

这里的目的是向你展示足够的React,以涵盖那些 "Aha "时刻。我建议在你想深入了解的情况下查看文档。我不会重复它们。

请注意,你可以在CodePen中找到所有的例子,但你也可以跳到我的Github repo中找到一个完整的工作游戏。

第一个应用程序

你可以用各种方式引导React应用。下面是一个例子。

import React from 'https://cdn.skypack.dev/react'
import { render } from 'https://cdn.skypack.dev/react-dom'

const App = () => <h1>{`Time: ${Date.now()}`}</h1>

render(<App/>, document.getElementById('app')

起始点

我们已经学会了如何制作一个组件,我们可以大致判断我们需要什么。

import React, { Fragment } from 'https://cdn.skypack.dev/react'
import { render } from 'https://cdn.skypack.dev/react-dom'

const Moles = ({ children }) => <div>{children}</div>
const Mole = () => <button>Mole</button>
const Timer = () => <div>Time: 00:00</div>
const Score = () => <div>Score: 0</div>

const Game = () => (
  <Fragment>
    <h1>Whac-A-Mole</h1>
    <button>Start/Stop</button>
    <Score/>
    <Timer/>
    <Moles>
      <Mole/>
      <Mole/>
      <Mole/>
      <Mole/>
      <Mole/>
    </Moles>
  </Fragment>
)

render(<Game/>, document.getElementById('app'))

开始/停止

在我们做任何事情之前,我们需要能够启动和停止游戏。开始游戏将触发像计时器和鼹鼠这样的元素,让它们活过来。这就是我们可以引入条件渲染的地方。

const Game = () => {
  const [playing, setPlaying] = useState(false)
  return (
    <Fragment>
      {!playing && <h1>Whac-A-Mole</h1>}
      <button onClick={() => setPlaying(!playing)}>
        {playing ? 'Stop' : 'Start'}
      </button>
      {playing && (
        <Fragment>
          <Score />
          <Timer />
          <Moles>
            <Mole />
            <Mole />
            <Mole />
            <Mole />
            <Mole />
          </Moles>
        </Fragment>
      )}
    </Fragment>
  )
}

我们有一个playing 的状态变量,我们用它来渲染我们需要的元素。在JSX中,我们可以使用一个带有&& 的条件,如果条件是true ,就渲染一些东西。在这里,我们说如果我们正在玩,就渲染棋盘和它的内容。这也会影响到按钮文本,我们可以使用三元组

打开这个链接中的演示,将扩展名设置为高亮渲染。接下来,你会看到计时器会随着时间的变化而渲染,但当我们敲击鼹鼠时,所有组件都会重新渲染。

JSX中的循环

你可能会想,我们渲染Mole的方式效率很低。你这样想是对的!这里有一个机会,我们可以用循环的方式渲染这些东西

对于JJSX,我们倾向于在99%的情况下使用Array.map 来渲染一个事物的集合。比如说

const USERS = [
  { id: 1, name: 'Sally' },
  { id: 2, name: 'Jack' },
]
const App = () => (
  <ul>
    {USERS.map(({ id, name }) => <li key={id}>{name}</li>)}
  </ul>
)

另一种方法是在for循环中生成内容,然后渲染一个函数的返回。

return (
  <ul>{getLoopContent(DATA)}</ul>
)

那个key 属性是做什么的?这可以帮助React确定哪些变化需要渲染。如果你可以使用一个唯一的标识符,那就这样做吧作为最后的手段,使用集合中项目的索引。阅读关于列表的文档了解更多)。

对于我们的例子,我们没有任何数据可以使用。如果你需要生成一个事物的集合,那么这里有一个技巧你可以使用。

new Array(NUMBER_OF_THINGS).fill().map()

这在某些情况下可能对你有用。

return (
  <Fragment>
    <h1>Whac-A-Mole</h1>
    <button onClick={() => setPlaying(!playing)}>{playing ? 'Stop' : 'Start'}</button>
    {playing &&
      <Board>
        <Score value={score} />
        <Timer time={TIME_LIMIT} onEnd={() => console.info('Ended')}/>
        {new Array(5).fill().map((_, id) => 
          <Mole key={id} onWhack={onWhack} />
        )}
      </Board>
    }
  </Fragment>
)

或者,如果你想要一个持久的集合,你可以使用像uuid

import { v4 as uuid } from 'https://cdn.skypack.dev/uuid'
const MOLE_COLLECTION = new Array(5).fill().map(() => uuid())

// In our JSX
{MOLE_COLLECTION.map((id) => 
  
)}

结束游戏

我们只能用 "开始 "按钮来结束我们的游戏。当我们真的结束它时,当我们再次开始时,分数仍然存在。我们的onEndTimer ,也还没有什么作用。

我们要引入一个第三方的解决方案,让我们的鼹鼠上下晃动。这是一个关于如何引入与DOM一起工作的第三方解决方案的例子。在大多数情况下,我们使用参照物来抓取DOM元素,然后在一个效果中使用我们的解决方案。

我们将使用GreenSock(GSAP)来使我们的鼹鼠晃动。我们今天不会深入研究GSAP的API,但如果你对它们的作用有任何疑问,请问我吧

这里有一个更新的Mole ,有GSAP

import gsap from 'https://cdn.skypack.dev/gsap'

const Mole = ({ onWhack }) => {
  const buttonRef = useRef(null)
  useEffect(() => {
    gsap.set(buttonRef.current, { yPercent: 100 })
    gsap.to(buttonRef.current, {
      yPercent: 0,
      yoyo: true,
      repeat: -1,
    })
  }, [])
  return (
    <div className="mole-hole">
      <button
        className="mole"
        ref={buttonRef}
        onClick={() => onWhack(MOLE_SCORE)}>
        Mole
      </button>
    </div>
  )
}

我们给button 添加了一个包装器,它允许我们显示/隐藏Mole ,我们也给我们的button 一个ref 。使用一个效果,我们可以创建一个Tween(GSAP动画),使按钮上下移动。

你还会注意到,我们正在使用className ,这是JSX中与class 相等的属性,用于应用类名。为什么我们不使用GSAP中的className ?因为如果我们有很多元素有那个className ,我们的效果会试图使用它们全部。这就是为什么useRef 是一个很好的选择,可以坚持使用。

请看《笔8》。Moving Molesby@jh3y.

太棒了,现在我们有了会晃动的Mole,从功能上来说,我们的游戏已经完成了。他们都是完全一样的移动,这并不理想。它们应该以不同的速度运行Mole 被击打的时间越长,得分也应该减少。

我们的鼹鼠的内部逻辑可以处理得分和速度如何更新的问题。将初始的speeddelaypoints 作为道具传入,将使组件更加灵活。

<Mole key={index} onWhack={onWhack} points={MOLE_SCORE} delay={0} speed={2} />

现在,对我们的Mole 逻辑进行分解。

让我们从我们的分数将如何随时间减少开始。这可能是一个很好的候选ref 。我们有一个不影响渲染的东西,它的值可能会在一个闭合中丢失。我们在一个效果中创建了我们的动画,它永远不会被重新创建。在我们的动画的每一次重复中,我们想通过一个倍数来减少points 的值。点值可以有一个最小值,由一个pointsMin 的道具定义。

const bobRef = useRef(null)
const pointsRef = useRef(points)

useEffect(() => {
  bobRef.current = gsap.to(buttonRef.current, {
    yPercent: -100,
    duration: speed,
    yoyo: true,
    repeat: -1,
    delay: delay,
    repeatDelay: delay,
    onRepeat: () => {
      pointsRef.current = Math.floor(
        Math.max(pointsRef.current * POINTS_MULTIPLIER, pointsMin)
      )
    },
  })
  return () => {
    bobRef.current.kill()
  }
}, [delay, pointsMin, speed])

我们也要创建一个ref ,为我们的GSAP动画保留一个参考。当Mole 被捶打时,我们将使用这个。注意我们还返回了一个函数,在卸载时杀死动画。如果我们不在卸载时杀死动画,重复代码就会继续发射。

见笔9.减分,作者:@jh3y

当鼹鼠被捶打时,会发生什么?我们需要一个新的状态来解决这个问题。

const [whacked, setWhacked] = useState(false)

而不是使用我们的buttononClick 中的道具onWhack ,我们可以创建一个新的函数whack 。这将把whacked 设置为true ,并以当前的pointsRef 值调用onWhack

const whack = () => {
 setWhacked(true)
 onWhack(pointsRef.current)
}

return (
 <div className="mole-hole">
    <button className="mole" ref={buttonRef} onClick={whack}>
      Mole
    </button>
  </div>
)

最后要做的是用useEffect 来响应效果中的whacked 状态。使用依赖性数组,我们可以确保只有在whacked 发生变化时才运行这个效果。如果whackedtrue ,我们就重置点,暂停动画,并将Mole 在地下制作动画。一旦进入地下,我们在重新启动动画之前等待一个随机延迟。使用timescale ,动画的启动速度会加快,我们将whacked 设为false

useEffect(() => {
  if (whacked) {
    pointsRef.current = points
    bobRef.current.pause()
    gsap.to(buttonRef.current, {
      yPercent: 100,
      duration: 0.1,
      onComplete: () => {
        gsap.delayedCall(gsap.utils.random(1, 3), () => {
          setWhacked(false)
          bobRef.current
            .restart()
            .timeScale(bobRef.current.timeScale() * TIME_MULTIPLIER)
        })
      },
    })
  }
}, [whacked])

这就给了我们。

见笔者10。React to Whacksby@jh3y.

最后要做的是向我们的Mole 实例传递道具,这将使它们表现得不同。但是,我们如何生成这些道具可能会导致一个问题。

<div className="moles">
  {new Array(MOLES).fill().map((_, id) => (
    <Mole
      key={id}
      onWhack={onWhack}
      speed={gsap.utils.random(0.5, 1)}
      delay={gsap.utils.random(0.5, 4)}
      points={MOLE_SCORE}
    />
  ))}
</div>

这将导致一个问题,因为当我们生成鼹鼠时,道具会在每次渲染时改变。一个更好的解决方案是,在我们每次启动游戏时生成一个新的Mole 数组,并在上面进行迭代。这样,我们就可以保持游戏的随机性,而不会造成问题。

const generateMoles = () => new Array(MOLES).fill().map(() => ({
  speed: gsap.utils.random(0.5, 1),
  delay: gsap.utils.random(0.5, 4),
  points: MOLE_SCORE
}))
// Create state for moles
const [moles, setMoles] = useState(generateMoles())
// Update moles on game start
const startGame = () => {
  setScore(0)
  setMoles(generateMoles())
  setPlaying(true)
  setFinished(false)
}
// Destructure mole objects as props
<div className="moles">
  {moles.map(({speed, delay, points}, id) => (
    <Mole
      key={id}
      onWhack={onWhack}
      speed={speed}
      delay={delay}
      points={points}
    />
  ))}
</div>

这就是结果!我已经为我们的按钮添加了一些样式和一些品种的鼹鼠。

请看笔者的11。运作中的打地鼠,作者:@jh3y

我们现在有了一个在React中构建的完全有效的 "打地鼠 "游戏。我们花了不到200行的代码。在这个阶段,你可以把它带走并使其成为你自己的。按你喜欢的方式设计它,添加新的功能,等等。或者你可以坚持下去,我们可以把一些额外的东西放在一起!

追踪最高分

我们有一个工作的 "打地鼠",但我们如何跟踪我们取得的最高分数?我们可以使用一个效果,在每次游戏结束时把我们的分数写到localStorage 。但是,如果持久化的东西是一个普遍的需求呢。我们可以创建一个名为usePersistentState 的自定义钩子。这可以是一个围绕着useState 的包装器,它可以读/写到localStorage

  const usePersistentState = (key, initialValue) => {
  const [state, setState] = useState(
    window.localStorage.getItem(key)
      ? JSON.parse(window.localStorage.getItem(key))
      : initialValue
  )
  useEffect(() => {
    window.localStorage.setItem(key, state)
  }, [key, state])
  return [state, setState]
}

然后我们可以在我们的游戏中使用它。

const [highScore, setHighScore] = usePersistentState('whac-high-score', 0)

我们使用它与useState 完全一样。我们还可以在游戏过程中,在适当的时候连接到onWhack ,设置一个新的高分。

const endGame = points => {
  if (score > highScore) setHighScore(score) // play fanfare!
}

我们怎样才能知道我们的游戏结果是否是一个新的高分?另一个状态?很有可能。

见笔者12。追踪高分,作者:@jh3y

奇思妙想的点缀

在这个阶段,我们已经涵盖了我们需要的一切。甚至包括如何制作你自己的自定义钩子。请自由发挥,把它变成你自己的。

还在犹豫吗?让我们创建另一个自定义钩子来为我们的游戏添加音频。

const useAudio = (src, volume = 1) => {
  const [audio, setAudio] = useState(null)
  useEffect(() => {
    const AUDIO = new Audio(src)
    AUDIO.volume = volume
    setAudio(AUDIO)
  }, [src])
  return {
    play: () => audio.play(),
    pause: () => audio.pause(),
    stop: () => {
      audio.pause()
      audio.currentTime = 0
    },
  }
}

这是一个用于播放音频的初级钩子实现。我们提供一个音频src ,然后我们得到播放它的API。我们可以在 "whac "鼹鼠的时候添加噪音。然后决定是,这是不是Mole 的一部分?它是我们传递给Mole 的东西吗?它是我们在onWhack 中调用的东西吗?

这些都是在组件驱动的开发中出现的决策类型。我们需要考虑到可移植性。另外,如果我们想让音频静音,会发生什么**?**我们怎样才能在全球范围内做到这一点?作为第一种方法,在Game 组件中控制音频可能更合理。

// Inside Game
const { play: playAudio } = useAudio('/audio/some-audio.mp3')
const onWhack = () => {
  playAudio()
  setScore(score + points)
}

这都是关于设计和决定。如果我们带入大量的音频,重命名play 变量可能会变得很繁琐。从我们的类似于钩子的useState 中返回一个数组,将允许我们为变量命名任何我们想要的东西。但是,也可能很难记住数组的哪个索引代表哪个API方法。

请看《笔13》。吱吱叫的鼹鼠》,作者:@jh3y

就这样吧!

足够让你开始你的React之旅了,而且我们还得做一些有趣的东西。我们确实涵盖了很多内容。

  • 创建一个应用程序。
  • JSX。
  • 组件和道具。
  • 创建定时器。
  • 使用参考文献。
  • 创建自定义钩子。

我们做了一个游戏!现在你可以用你的新技能来增加新的功能或使它成为你自己的。

我把它带到哪里了?在写这篇文章的时候,到目前为止是在这个阶段。

请看@jh3y的PenWhac-a-Mole w/ React &&GSAP

下一步该怎么走!

我希望建造 "打地鼠 "能激励你开始你的React之旅。下一步是什么?如果你想深入研究,这里有一些资源的链接,其中一些是我在这一路上发现的有用资源。