magent---猜字游戏

36 阅读5分钟
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>拖拽、回弹和吸附的成语匹配</title>
    <script>
      document.documentElement.style.fontSize = '10px'
    </script>
    <style>
      body {
        margin: 0;
        background-color: #114b5f;
      }

      div {
        display: flex;
        flex-direction: column;
      }

      /* 盒子组容器 flex 布局 */
      .blank-cell-group,
      .char-cell-group {
        width: 100%;
        flex-direction: row;
      }

      /* 让每个盒子是一个正方形,并用 padding 添加间隙 */
      .blank-cell-group .cell-item,
      .char-cell-group .cell-item {
        width: 25%;
        height: 25vw;
        padding: 0.5rem;
        box-sizing: border-box;
      }

      /* 给盒子添加边框 */
      .blank-cell-group .cell-item .wrapper,
      .char-cell-group .cell-item .wrapper {
        width: 100%;
        height: 100%;
        border: 0.5rem solid #456990;
        box-sizing: border-box;
        border-radius: 1rem;
      }

      /* 字符盒子 flex wrap */
      .char-cell-group {
        flex-wrap: wrap;
        margin-top: 5rem;
      }

      /* 字符盒子单独的样式 */
      .char-cell-group .cell-item .wrapper {
        justify-content: center;
        align-items: center;
        font-size: 3rem;
        color: #e4fde1;
        border: none;
        background-color: #456990;
      }
    </style>
  </head>
  <body>
    <div id="app">
      <div class="container">
        <!-- 顶部空白盒子 -->
        <div class="blank-cell-group">
          <div class="cell-item">
            <div class="wrapper"></div>
          </div>
          <div class="cell-item">
            <div class="wrapper"></div>
          </div>
          <div class="cell-item">
            <div class="wrapper"></div>
          </div>
          <div class="cell-item">
            <div class="wrapper"></div>
          </div>
        </div>
        <!-- 字符盒子 -->
        <div class="char-cell-group"></div>
      </div>
    </div>

    <script>
      // 立即执行函数 IIFE 用于形成一个单独的作用域
      ;(() => {
        const idioms = ['诗情画意', '南来北往', '一团和气', '落花流水'],
          oCharCellGroup = document.querySelector('.char-cell-group'),
          oBlanks = document.querySelectorAll('.blank-cell-group .wrapper')
        let charCollection = [],
          charAreas = [],
          blankAreas = [],
          blankStates = [undefined, undefined, undefined, undefined], // {char: '水', el: 字符盒子DOM元素}
          oChars = null,
          startX = 0,
          startY = 0,
          cellX = 0,
          cellY = 0,
          cellH = 0,
          cellW = 0,
          mouseX = 0,
          mouseY = 0

        // ============================== 初始化函数 ==============================
        const init = () => {
          // 空白盒子的坐标在一开始就可以获取,因为是静态的
          getAreas(oBlanks, blankAreas)

          charCollection = formatCharsArr() // 将成语数组打乱成随机单个字符组成的数组
          render()

          oChars = oCharCellGroup.querySelectorAll('.cell-item .wrapper')
          getAreas(oChars, charAreas)
          bindEvents() // 肯定是要等所有的字符都渲染出来了才能够获取到它们并绑定事件
        }
        init()

        // ============================== 普通函数 ==============================
        // 将成语数组拆解成单个字符的数组,并且是乱序的
        function formatCharsArr() {
          // 私有变量
          let _arr = []
          idioms.forEach((item) => {
            _arr = _arr.concat(item.split(''))
          })

          return _arr.sort(randomSort)
        }

        // 将成语字符数组随机排序
        function randomSort(a, b) {
          return Math.random() > 0.5 ? -1 : 1
        }

        // 获取所有字符盒子的初始坐标并存入数组中
        function getAreas(domCollection, arrWrapper) {
          let startX = 0,
            startY = 0,
            oItem = null

          for (let i = 0; i < domCollection.length; i++) {
            oItem = domCollection[i]
            startX = oItem.offsetLeft
            startY = oItem.offsetTop

            arrWrapper.push({
              startX,
              startY,
            })
          }
        }

        // 答案验证
        function checkAnwser() {
          // 1. 从空白盒子中获取到输入的成语
          let idiom = ''
          blankStates.forEach((item) => {
            idiom += item.char
          })

          // 2. 判断成语是否在 idioms 中 -- 由于 alert 会阻塞浏览器,因此要放入计时器中延迟运行
          setTimeout(() => {
            if (idioms.includes(idiom)) {
              alert('正确')
            } else {
              alert('错误')
            }

            // 3. 将字符盒子恢复到原来的位置
            blankStates.forEach((item) => {
              const charEl = item.el
              const index = charEl.dataset.index

              setPosition(charEl, [
                charAreas[index].startX,
                charAreas[index].startY,
              ])
            })

            // 4. 清空空白盒子状态 blankStates
            for (let i = 0; i < blankStates.length; i++) {
              blankStates[i] = undefined
            }
          }, 500)
        }

        // 将 px 转为 rem
        function pxToRem(item) {
          return item / 10 + 'rem'
        }

        // 处理吸附逻辑 -- 返回 true 表示 charEl 当前吸附成功
        function handleAdsorbed(charEl) {
          // 吸附的逻辑 -- 被拖拽元素占空白盒子的面积大于 50% 时就吸附
          // 1. 获取空白盒子的面积
          const blankArea = oBlanks[0].offsetWidth * oBlanks[0].offsetHeight

          // 2. 遍历每一个空白盒子来判断当前被拖拽的元素应该放入哪个空白盒子中
          for (let i = 0; i < oBlanks.length; i++) {
            // 判断当前盒子是否已有元素存放,有的话直接跳过这轮遍历
            if (blankStates[i] !== undefined) {
              continue
            }

            // 能来到这说明当前空白盒子中没有字符盒子,可以判断吸附逻辑了

            // 3. 计算被拖拽盒子占据空白盒子的面积百分比
            const oBlank = oBlanks[i]
            let occupiedArea = 0

            // 垂直长度 = 空白盒子高度 - (this 纵坐标 - 空白盒子上边框纵坐标)
            const verticalLength =
              oBlank.offsetHeight - (charEl.offsetTop - oBlank.offsetTop)
            let horizontalLength = 0

            // charEl 左边框在空白盒子左边框的 左边 和 右边 时,水平长度的计算方式不同,需要分开处理
            if (charEl.offsetLeft >= oBlank.offsetLeft) {
              // charEl 左边框在盒子左边框右边时的占用面积
              // 水平长度 = 空白盒子右边框横坐标 - charEl 横坐标
              horizontalLength =
                oBlank.offsetLeft + oBlank.offsetWidth - charEl.offsetLeft
            } else {
              // charEl 左边框在盒子左边框左边时的占用面积
              // 水平长度 = charEl 右边框横坐标 - 空白盒子左边框横坐标
              horizontalLength =
                charEl.offsetLeft + charEl.offsetWidth - oBlank.offsetLeft
            }

            // 占用面积 = 水平长度 * 垂直长度
            occupiedArea =
              horizontalLength > 0 && verticalLength > 0
                ? horizontalLength * verticalLength
                : 0

            if (occupiedArea / blankArea >= 0.5) {
              // 4. 吸附 --> 将 charEl 的坐标设置为 当前空白盒子的坐标
              setPosition(charEl, [oBlank.offsetLeft, oBlank.offsetTop])

              // 5. 更新当前空白盒子的状态信息 --> {char: charEl.innerText, el: charEl}
              blankStates[i] = {
                char: charEl.innerText,
                el: charEl,
              }

              return true // 吸附后不执行回弹逻辑
            }
          }
        }

        // 处理回弹逻辑
        function handleReBound(charEl) {
          const _index = parseInt(charEl.dataset.index),
            charArea = charAreas[_index]

          // 将字符盒子的 top 和 left 设置为 charAreas[_index] 对应值
          setPosition(charEl, [charArea.startX, charArea.startY])
        }

        // 修改 DOM 元素坐标 -- position 是一个数组 [x, y]
        function setPosition(dom, position) {
          dom.style.left = pxToRem(position[0])
          dom.style.top = pxToRem(position[1])
        }

        // ============================== 渲染相关的函数 ==============================
        // 字符盒子 DOM 元素模板
        function charCellTpl(char, index) {
          return `
              <div class="cell-item">
                <div class="wrapper" data-index="${index}">${char}</div>
              </div>
              `
        }

        // 渲染函数
        function render() {
          let list = ''

          charCollection.forEach((char, index) => {
            list += charCellTpl(char, index)
          })

          oCharCellGroup.innerHTML = list
        }

        // ============================== 事件绑定函数 ==============================
        function bindEvents() {
          let oChar = null

          for (let i = 0; i < oChars.length; i++) {
            oChar = oChars[i]
            oChar.addEventListener('touchstart', handleTouchStart, false)
            oChar.addEventListener('touchmove', handleTouchMove, false)
            oChar.addEventListener('touchend', handleTouchEnd, false)
          }
        }

        function handleTouchStart(e) {
          // 1. 确定所有需要用到的变量
          cellW = this.offsetWidth // offsetXXX 等于 border-box 盒模型的宽高
          cellH = this.offsetHeight
          cellX = this.offsetLeft
          cellY = this.offsetTop
          startX = e.touches[0].clientX // 第一个触摸点距离 viewport 的 X 坐标
          startY = e.touches[0].clientY
          mouseX = startX - cellX
          mouseY = startY - cellY

          // 2. display 设置成 fixed
          this.style.position = 'fixed'

          // 3. 清除设置过的动画
          this.style.transition = ''

          // 4. 设置坐标和宽高

          // cellW 是 px 单位,而根的 font-size 是 10px,这样写就能保证和触摸前是一样的宽高
          this.style.width = pxToRem(cellW)
          this.style.height = pxToRem(cellH)
          setPosition(this, [cellX, cellY])
        }

        function handleTouchMove(e) {
          const moveX = e.touches[0].clientX
          const moveY = e.touches[0].clientY

          cellX = moveX - mouseX
          cellY = moveY - mouseY

          setPosition(this, [cellX, cellY])
        }

        function handleTouchEnd(e) {
          // 加上 transition 动画,让盒子回弹效果更加顺滑
          const transitionDuration = '.5s',
                transitionTimeFunction = 'ease'
          this.style.transition = `top ${transitionDuration} ${transitionTimeFunction}, left ${transitionDuration} ${transitionTimeFunction}`

          // ============= 吸附逻辑 =============
          let isAdsorbed = handleAdsorbed(this) // 标志当前被拖拽盒子是否已经吸附 吸附了就不执行回弹逻辑

          // 答案验证 -- 当且仅当所有空白盒子填写完毕的时候才验证答案
          if (!blankStates.includes(undefined)) checkAnwser()

          // ============= 回弹逻辑 =============
          if (!isAdsorbed) handleReBound(this)
        }
      })()
    </script>
  </body>
</html>