阅读视图

发现新文章,点击刷新页面。

我用JavaScript复刻了某宝的小游戏动物大迁徙消消乐

按照惯例线上预览图。

xxl.gif

前段时间在某宝的养鸡小游戏里面发现一个年代感小游戏,为了小鸡饲料特意点进去玩了一段时间,然后小游戏里面有个"动物大迁移"的消消乐特别上头,但是这个游戏在里面只有活动的时间才能玩,而且每次这个活动要等一个月,还只能玩三天。为了我后面能畅玩,就想着自己能不能也写一个,这样就不用等活动来了,然后就有了这次的游戏,想用JavaScript实现,应该挺有意思的!于是说干就干,开始了我的复刻之旅。

项目背景

其实我一直对游戏开发很感兴趣,但总觉得门槛太高。看到到玩到这个消消乐,发现它的规则简单但很有策略性:不同长度的动物方块、特殊的野牛机制、冰冻技能...这简直就是完美的新手练手项目!

游戏设计思路

首先是游戏设计,我的目标是复刻核心玩法,或许后面也要加入一些自己的特色:比如自由模式或者AI?

动物方块设计

  • 1格:鸵鸟(基础方块)
  • 2格:斑马 / 麋鹿(中等长度)
  • 3格:大象 / 狮子(较长方块)
  • 4格:北极熊(特殊技能方块)
  • 5格:野牛(BOSS级方块)

核心机制

  • 整行消除得分,BOSS野牛方块消除是累计的,不是一次性消除
  • 连击倍数奖励,连击分数有加成效果
  • 技能点积累,技能点不能是无限制的,需要消除获取,最多可以储存2次,越到后期技能获取的条件越高
  • 冰冻模式和每个动物都有的独特技能,通过后期使用技能来解决较长的方块
  • 预加载方块,可以在底部查看到下次出来的是那些方块,以便后续布局
  • 迁徙动画,可以在顶部看到动物消除之后,对应的动物从左到右奔跑出来

核心架构

我将游戏分为四个核心模块,采用面向对象的设计思想:

GameState:游戏状态管理

负责维护游戏的所有状态数据,包括棋盘状态、分数、技能点数等。

GameRenderer:渲染系统

处理所有视觉相关的逻辑,包括方块渲染、动画效果等。

GameLogic:游戏逻辑

实现游戏的核心规则,包括消除判断、连击系统等。

GameController:控制层

处理用户输入,协调各个模块的协作。

GameSkill:技能输出

负责处理方块对应技能,确保每个方块输出的技能。

GameSound:音频管理

负责整个游戏的音频输出,可以设置背景音乐和点击音效等。

各模块代码结构清晰,各模块职责单一,用于后面维护和扩展。

开发中的设计和技术难点

设计

动物方块技能

每个长度的动物都有自己的设计,这里我主要说一下 boss 野牛北极熊 设计。

作为BOSS级别的存在,野牛方块的消除机制和其他的消除不同,需要多次累计消除才能完全清除,而且越到后期野牛的出现几率就越大,解决野牛的最好办法就是使用北极熊冰冻技能,是控制场上所有的北极熊使移动回合暂停,让所有的 boss野牛 变的温顺变为一格!

技能点数系统

技能系统在游戏中也是非常重要的,根据游戏设计完整的技能点数积累机制。

// 技能状态管理
this.skill = {
    currentPoints: 0,      // 当前积累点数
    maxPoints: 2500,      // 点数上限
    threshold: 1000,      // 每个技能点需要的点数
    skillPoint: 0         // 可用的技能点数
}

开局只有1000点的积分点,通过消除获取积分点得到技能点,超过1000积分累计一个技能,默认最大2500积分,超过积分不累计,直到第一次使用积分。使用积分,默认最大积分和第一积分点会累加。

核心逻辑,点击使用机会按钮后:

  • 每个技能点需要的点数的阈值变为1500(即2500-1000)
  • 点数上限的最大值变为3500(即2500+1000)
handleSkillPointsClick() {
  if (this.state.skill.skillPoint <= 0) {
    this.renderer.showMessage({ message: '技能点不足!' })
    return
  }
  if (this.isSelectingSkillTarget || this.state.isFreezeMode) {
    return
  }
  // 更新阈值和最大值
  const newThreshold = this.state.skill.maxPoints - this.state.skill.threshold
  this.state.skill.maxPoints = this.state.skill.maxPoints + this.state.skill.threshold
  this.state.skill.currentPoints = this.state.skill.currentPoints - this.state.skill.threshold
  this.state.skill.threshold = newThreshold

  this.soundManager.play('falling')

  // 进入“等待选择技能目标”模式
  this.isSelectingSkillTarget = true
  this.gameMaskElement.classList.add('show')

  this.renderer.updateScore()
}

预加载方块

通过预加载可以提前知道接下来生成的方块位置和大小,方便后续提前移动布局。这里面在示例中设置的是9x11的大小棋盘,但是实际渲染的是9x12大小,多出来的一行是预加载行,样式上设置overflow:hidden隐藏,通过生成动画加载向上移动一行。

generateNewRow() {
  // 检查并更新下一个野牛生成回合
  if (this.round === this.nextBuffaloRound) {
    if (this.buffaloIndex < this.buffaloPattern.length - 1) {
      this.buffaloIndex++
    }
    this.nextBuffaloRound += this.buffaloPattern[this.buffaloIndex]
  }
  // 检查是否需要生成野牛行
  if (this.round + 1 === this.nextBuffaloRound) {
    return this.generateBuffaloRow()
  }
  // 默认动物
  const animals = {
    1: 'ostrich', // 鸵鸟
    2: 'zebra,deer', // 斑马,麋鹿
    3: 'elephant,lion', // 大象,狮子
    4: 'bear' // 北极熊
  }
  // 创建新行数组
  const newRow = Array(this.boardSizeX).fill(null)
  // 随机生成方块组个数
  const groupCount = this.getRandomInt(2, 4)
  // 生成随机起始位置[0,2],避免每次都是从第一个开始
  let usedCells = this.getRandomInt(0, 2)

  for (let i = 0; i < groupCount; i++) {
    if (usedCells >= this.boardSizeX) break
    // 使用智能几率生成方块长度
    const weightLength = this.getWeightedRandomLength()
    // 随机生成方块组的随机长度,最大不超过4格
    const maxLength = Math.min(4, this.boardSizeX - usedCells)
    // 随机生成方块组长度,最小为1,最大为maxLength
    const length = Math.min(weightLength, maxLength)
    const animalArray = animals[length].split(',')
    const animal = animalArray[Math.floor(Math.random() * animalArray.length)]
    const startCol = usedCells

    // 创建方块组
    const blockId = this.nextBlockId++
    for (let j = 0; j < length; j++) {
      newRow[startCol + j] = {
        id: blockId,
        length: length,
        startCol: startCol,
        animal
      }
    }
    // 生成后续间隔的格子数,随机间隔0-2格
    usedCells += length + this.getRandomInt(0, 2)
  }

  return newRow
}

这里为了增加游戏难度,在越到后期,方块生成的类型肯定是不能随机出来,所以在生成的时候加入了生成方块的概率判断,通过 getWeightedRandomLength() 函数创建生成长度权重来增加游戏难度和可玩性😄。

// 根据权重随机生成方块长度
getWeightedRandomLength() {
  // 基础几率配置
  const chances = {
    1: 35, // 1格方块35%几率
    2: 30, // 2格方块30%几率
    3: 25, // 3格方块25%几率
    4: 10 // 4格方块10%几率
  }

  // 根据游戏进度调整几率(回合数越多,大方块几率越高)
  const progressFactor = Math.min(1, this.round / this.allRound) // 500回合后达到最大调整

  // 调整后的几率
  const adjustedChances = {
    1: Math.max(10, chances[1] - progressFactor * 20), // 1格几率减少
    2: Math.max(25, chances[2] - progressFactor * 10), // 2格几率减少
    3: Math.min(35, chances[3] + progressFactor * 10), // 3格几率增加
    4: Math.min(30, chances[4] + progressFactor * 20) // 4格几率增加
  }

  // 计算总几率
  const totalChance = adjustedChances[1] + adjustedChances[2] + adjustedChances[3] + adjustedChances[4]

  // 生成随机数
  const randomValue = Math.random() * totalChance

  // 根据几率选择方块长度
  let cumulative = 0

  cumulative += chances[1]
  if (randomValue <= cumulative) return 1

  cumulative += chances[2]
  if (randomValue <= cumulative) return 2

  cumulative += chances[3]
  if (randomValue <= cumulative) return 3

  return 4
}

动物的奔跑动画

这里采用的是 css 的关键帧样式,因为原版里面有很多动画js实现非常困难,索性直接用的css 加帧图片配合 keyframes 的连续移动做出动物奔跑动作。

使用padding-bottom 设置相对画布的百分比高度,计算公式 h = (图片高 H / 图片宽 W) * 相对宽度 w ,然后通过伪类 before 设置百分百宽高加上动画帧就可以了。

.animal-buffalo {
  position: absolute;
  bottom: 20%;
  right: 100%;
  width: 120px;
  height: 0;
  padding-bottom: calc(210 / (4950 / 15) * 120px);
  animation: buffalo 3s forwards ease-out;
}

.animal-buffalo::before {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: url('../img/buffalo.png');
  background-size: 1500% 100%;
  animation: buffalo1 0.8s steps(15) infinite;
}

@keyframes buffalo {
  to {
    right: -120px;
  }
}

@keyframes buffalo1 {
  from {
    background-position: 0 0;
  }
  to {
    background-position: -1500% 0;
  }
}

技术难点

这次开发有相对较多的技术小技巧,这里这是拿出比较重要关键的节点来说。

元素动画(核心)

在开始代码初期,使用的动画是 transition 过渡,但是在做的过程中发现,这个监听动画结束是非常不可控制,比如元素的创建到销毁开始是监听不到 transitionend 的事件的,必须要配合写 setTimeout 延迟才可以,这样的做法有点“丑陋”,我是看不得代码里面都是 setTimeout 控制动画结束做回调,思索之后选择自己写一个动画效果,在翻阅资料后,找到大佬张鑫旭的文章「如何使用Tween.js各类原生动画运动缓动算法」,事实上很早就看过这篇,现在迅速再翻一遍。具体实现步骤和原理这里不多介绍,有兴趣可以翻看文章。

根据文章提供的思路写了一个 animate 的初始函数。

function animate(options) {
  return new Promise((resolve) => {
    const startTime = performance.now();
    const { ele, begin, change, duration } = options;
    const [p1, p2, p3, p4] = [0.175, 0.885, 0.32, 1.275]; // cubic-bezier参数

    function frame(currentTime) {
      const elapsed = currentTime - startTime;
      const progress = Math.min(elapsed / duration, 1);
      
      // 使用精确的CSS缓动计算
      const easedProgress = preciseCubicBezier(progress, p1, p2, p3, p4);
      const currentValue = begin + change * easedProgress;

      ele.style.transform = `translateX(${currentValue}px)`;

      if (progress < 1) {
        requestAnimationFrame(frame);
      } else {
        resolve();
      }
    }

    requestAnimationFrame(frame);
  });
}

可以看出上面的函数还是有很大的局限性的,最大的问题是在游戏中需要消除时有抖动的效果的,这时候抖动是 0 -> 0 的过程,在这个过程中使用函数实际上不是运动的,所以针对抖动需要添加帧动画的模式。

dd.gif

Tween 的缓动方法做了其他思路的改变,加上 keyframe 的实现,因为在本游戏中有位移为0,但是中间做的偏移动画,例如首图中方块元素消除之后的左右摆动,开始位置是0,结束也是0,所以在原始的 Tween 是不奏效的,针对这个问题做了如下改动。

// 根据关键帧计算当前值的核心函数
getValueFromKeyframes(progress, keyframes, defaultEasing) {
  // 确保关键帧是按offset排序的
  const sortedKeyframes = [...keyframes].sort((a, b) => a.offset - b.offset)

  // 处理边界情况
  if (progress <= 0) return sortedKeyframes[0].value
  if (progress >= 1) return sortedKeyframes[sortedKeyframes.length - 1].value

  // 1. 定位段落:找到当前进度所在的关键帧段落
  let segmentStartFrame = sortedKeyframes[0]
  let segmentEndFrame = sortedKeyframes[sortedKeyframes.length - 1]
  for (let i = 0; i < sortedKeyframes.length - 1; i++) {
    if (progress >= sortedKeyframes[i].offset && progress <= sortedKeyframes[i + 1].offset) {
      segmentStartFrame = sortedKeyframes[i]
      segmentEndFrame = sortedKeyframes[i + 1]
      break
    }
  }

  // 2. 计算局部进度
  const segmentDuration = segmentEndFrame.offset - segmentStartFrame.offset
  // 避免除以零的错误
  if (segmentDuration === 0) return segmentEndFrame.value

  const localProgress = (progress - segmentStartFrame.offset) / segmentDuration

  // 3. 应用缓动
  // 优先使用段落指定的缓动,否则使用全局默认缓动
  const easing = segmentStartFrame.easing || defaultEasing
  const easedLocalProgress = this.preciseCubicBezier(localProgress, ...easing)

  // 4. 计算最终值 (线性插值)
  const valueChange = segmentEndFrame.value - segmentStartFrame.value
  const currentValue = segmentStartFrame.value + valueChange * easedLocalProgress

  return currentValue
}

animate({
  begin,
  end,
  keyframes,
  duration = this.options.duration,
  cubicBezier = this.options.cubicBezier,
  onUpdate,
  onEnd,
  onBefore
}) {
  return new Promise((resolve) => {
    // --- 兼容性处理 ---
    // 如果传入了 begin 和 change,则动态生成 keyframes
    if (begin !== undefined && end !== undefined && !keyframes) {
      keyframes = [
        { offset: 0, value: begin },
        { offset: 1, value: end }
      ]
    }
    // 如果没有有效的关键帧,则报错
    if (!keyframes || keyframes.length < 2) {
      console.error('关键帧最短需要两个或更多')
      resolve(false)
      return
    }

    const startTime = performance.now()

    const frame = (currentTime) => {
      const elapsed = currentTime - startTime
      const totalProgress = Math.min(elapsed / duration, 1)

      // 使用新的核心计算函数
      const currentValue = this.getValueFromKeyframes(totalProgress, keyframes, cubicBezier)

      onUpdate && onUpdate(currentValue)

      if (totalProgress < 1) {
        requestAnimationFrame(frame)
      } else {
        onEnd && onEnd()
        resolve(true)
      }
    }

    onBefore && onBefore()
    requestAnimationFrame(frame)
  })
}

在使用关键帧的时候可以加上 keyframes 字段,duration 时间以及自定义你的缓动动画 cubicBezier,非常自由。

animate({
  keyframes: [
    { offset: 0, value: 0 },
    { offset: 0.2, value: 3 },
    { offset: 0.4, value: -3 },
    { offset: 0.6, value: 4 },
    { offset: 0.8, value: -5 },
    { offset: 1, value: 0 }
  ],
  duration: 300,
  cubicBezier: [0.175, 0.885, 0.32, 1.275],
  onUpdate: (value) => {
    blockDom.style.left = `${value}px`
  }
})

方块拖动与碰撞检测

在方块的拖动时要确保只能在有空隙的地方拖拽,所以需要做平滑拖动检测。由于动物方块的占格长度不同,传统的网格碰撞检测无法直接使用,这个方法确保了方块只能在空位上移动,不会与其他方块重叠,同时保证了拖动的流畅性。

我设计了一套基于位置预测的碰撞系统:

const blockId = Number(block.dataset.blockId)
const row = Number(block.dataset.row)

let startCol = this.state.boardSizeX
let endCol = -1

// 找到整个方块组的起始和结束位置索引
for (let col = 0; col < this.state.boardSizeX; col++) {
  if (this.state.board[row][col] !== null && this.state.board[row][col].id === blockId) {
    startCol = Math.min(startCol, col)
    endCol = Math.max(endCol, col)
  }
}

this.currentBlockGroup = {
  id: blockId,
  row: row,
  startCol: startCol,
  length: endCol - startCol + 1
}

// 返回能移动的距离
calculateMaxLeftMove(blockGroup = this.currentBlockGroup) {
  let maxLeft = blockGroup.startCol
  for (let col = blockGroup.startCol - 1; col >= 0; col--) {
    if (this.state.board[blockGroup.row][col] === null) {
      maxLeft--
    } else {
      break
    }
  }
  return blockGroup.startCol - maxLeft
}

方块的下落检测

在方块下落的时候需要检测下面是否有空隙掉落,需要通过 do while 循环来判断,因为在下落的过程中程序是不知道自己要下落多上行,而且遍历循环是从下往上循环一次的,在这个过程中循环一次查到元素下面是空的就标记 `true,表示继续需要下落,如此往复,等对应起始元素下面有遮挡为止标记 false 跳出循环。

// 应用重力(返回是否有方块掉落)
applyGravity() {
  return new Promise(async (resolve) => {
    let moved
    let blocks = []
    do {
      moved = false

      // 从下往上检查
      for (let row = this.state.boardSizeH - 2; row >= 0; row--) {
        for (let col = 0; col < this.state.boardSizeX; col++) {
          // 只处理每个方块组的第一个格子
          if (this.state.board[row][col] === null || this.state.board[row][col].startCol !== col) continue

          const blockData = this.state.board[row][col]
          const blockLength = blockData.length

          // 检查下方是否有足够连续的空位
          let canFall = true
          for (let c = col; c < col + blockLength; c++) {
            if (this.state.board[row + 1][c] !== null) {
              canFall = false
              break
            }
          }

          // 如果可以下落,移动整个方块组
          if (canFall) {
            // 移动数据
            for (let c = col; c < col + blockLength; c++) {
              this.state.board[row + 1][c] = this.state.board[row][c]
              this.state.board[row][c] = null
            }

            // 记录移动的方块组
            const block = blocks.find((b) => b.blockId === blockData.id)
            if (block) {
              block.endRow = row + 1
            } else {
              blocks.push({
                blockId: blockData.id,
                startRow: row,
                endRow: row + 1,
                startCol: col,
                endCol: col,
                length: blockLength
              })
            }

            moved = true
            col += blockLength - 1 // 跳过已处理的方块组
          }
        }
      }
    } while (moved)

    await this.renderer.animateBlock(blocks, 'falling')
    resolve(moved)
  })
}

通过上面的程序就可以检测出需要下落的起始位置和结束位置了,最后再用统一用动画 animateBlock(blocks, 'falling') 函数处理动画过程。

方块消除

方块消除可以分成两个步骤,第一步检测需要消除的方块,有没有包含 boss 野牛 ,没有则整个删除,这样是最简单的,如果包含那么久要考虑消除除 boss 野牛 以外的方块,当然 boss 野牛 长度不能为1,否则也要视为普通方块。

检测消除之后还需要通过积分系统关联积分累计,然后等所有动画完成之后,再需要重新重复上面一步的下落检测。

// 检查并执行消除(返回是否有消除发生)
checkEliminations() {
  return new Promise(async (resolve) => {
    let blocks = []
    let blocks2 = []
    let elimination = false
    // 本次消除获得的积分
    let pointsEarned = 0
    let pointsEarned2 = 0

    for (let row = 0; row < this.state.boardSizeH; row++) {
      // 该行有空格,跳过
      if (this.state.board[row].some((cell) => cell === null)) continue
      // 该行无空格,执行消除
      elimination = true
      this.state.currentCombo++

      // 消除该行
      for (let col = 0; col < this.state.boardSizeX; col++) {
        if (!this.state.board[row][col]) continue

        // 计算连击倍数
        const index = Math.min(this.state.currentCombo - 1, this.state.multipliers.length - 1)
        const blockData = this.state.board[row][col]

        // 检查是否是野牛标记的方块
        if (blockData.animal !== 'buffalo') {
          if (!blocks.find((b) => b.blockId === blockData.id)) {
            const comboMultiplier = blockData.length * 10 * this.state.multipliers[index]
            blocks.push({
              blockId: blockData.id,
              startRow: row,
              endRow: row,
              startCol: col,
              endCol: col,
              length: blockData.length,
              animal: blockData.animal,
              comboMultiplier
            })
            // 计算积分:方块长度 × 10 × 连击倍数
            pointsEarned += comboMultiplier
            pointsEarned2 += blockData.length * 10
          }
          this.state.board[row][col] = null
        } else {
          if (!blocks2.find((b) => b.blockId === blockData.id)) {
            // 找到野牛方块的最后一个格子
            const lastCol = blockData.startCol + blockData.length - 1
            // 更新野牛方块数据
            this.state.board[row][lastCol] = null
            const data = {
              blockId: blockData.id,
              startRow: row,
              endRow: row,
              startCol: col,
              endCol: col,
              startLength: blockData.length,
              endLength: blockData.length - 1,
              animal: blockData.animal
            }
            // 如果野牛消除只剩下一格,积分固定200
            if (data.startLength === 1) {
              const comboMultiplier = 200 * this.state.multipliers[index]
              blocks.push({
                ...data,
                length: 200,
                comboMultiplier
              })
              pointsEarned += comboMultiplier
              pointsEarned2 += 200
            } else {
              blocks2.push(data)
            }
          }
          // 只减少长度
          if (this.state.board[row][col]) {
            this.state.board[row][col].length = blockData.length - 1
          }
        }
      }
    }

    if (elimination) {
      const messages = {
        2: '双连击!',
        3: '三连击!!',
        4: '四连击!!!',
        5: '五连击!!!!超神!'
      }

      const message = messages[this.state.currentCombo] || `${this.state.currentCombo}连击!`
      if (messages[this.state.currentCombo]) {
        this.renderer.showMessage({ message })
      }
      // 添加积分
      this.state.addPoints(pointsEarned, pointsEarned2)

      // 更新分数显示
      this.renderer.updateScore()
      const animations = [this.renderer.animateBlock(blocks, 'eliminating')]
      if (blocks2.length) {
        animations.push(this.renderer.animateBlock(blocks2, 'buffalo'))
      }

      await Promise.all(animations)
    }
    resolve(elimination)
  })
}

方块消除循环检测

在上面拿到两个函数之后,其实循环在检测就简单,根据上面说的 do while 是个很好用的循环,每次函数执行会自动调用一次,如果循环体里面为 true 则表示还有需要下落的方块或者需要消除的行。

// 处理游戏效果(掉落、消除等)
async processGameEffects() {
  let hasChanges
  do {
    hasChanges = false

    // 应用重力
    const fell = await this.applyGravity()

    // 检查消除
    const eliminated = await this.checkEliminations()

    hasChanges = fell || eliminated
  } while (hasChanges)
}

结尾

中间还加了游戏需要背景音乐和音效,确保游戏进程不单调,总体游戏算是完成了绝大部分,还有一些技能后续也会补上,后续也会考虑怎么改成canvas版再加上,自由模式自由添加,包括AI辅助功能。

做这个项目也收获挺多,整体素材和游戏玩法都是扣原版的,最麻烦的地方是素材都是我一个个ps整的,这就花了大部分的时间,事实上代码逻辑不复杂,组合起来主要的点却又很多,中间也是边看录得视频一边琢磨玩法才到现在的完成版,这个动物消消乐项目从一个偶然的灵感开始,最终成为了我技术成长的重要里程碑。整个过程中遇到的每个挑战都成为了宝贵的学习机会。

最后大家完了觉得还不错或者说不足建议,可以在评论区留言指出,如果觉得这篇文章有帮助,请点个赞支持一下哦!

项目源码GitHub链接

在线体验预览链接


注:本文仅分享技术学习经验,相关游戏素材和机制已进行差异化设计,如有侵权请联系删除

❌