按照惯例线上预览图。

前段时间在某宝的养鸡小游戏里面发现一个年代感小游戏,为了小鸡饲料特意点进去玩了一段时间,然后小游戏里面有个"动物大迁移"的消消乐特别上头,但是这个游戏在里面只有活动的时间才能玩,而且每次这个活动要等一个月,还只能玩三天。为了我后面能畅玩,就想着自己能不能也写一个,这样就不用等活动来了,然后就有了这次的游戏,想用JavaScript实现,应该挺有意思的!于是说干就干,开始了我的复刻之旅。
项目背景
其实我一直对游戏开发很感兴趣,但总觉得门槛太高。看到到玩到这个消消乐,发现它的规则简单但很有策略性:不同长度的动物方块、特殊的野牛机制、冰冻技能...这简直就是完美的新手练手项目!
游戏设计思路
首先是游戏设计,我的目标是复刻核心玩法,或许后面也要加入一些自己的特色:比如自由模式或者AI?
动物方块设计:
- 1格:鸵鸟(基础方块)
- 2格:斑马 / 麋鹿(中等长度)
- 3格:大象 / 狮子(较长方块)
- 4格:北极熊(特殊技能方块)
- 5格:野牛(BOSS级方块)
核心机制:
- 整行消除得分,BOSS野牛方块消除是累计的,不是一次性消除
- 连击倍数奖励,连击分数有加成效果
- 技能点积累,技能点不能是无限制的,需要消除获取,最多可以储存2次,越到后期技能获取的条件越高
- 冰冻模式和每个动物都有的独特技能,通过后期使用技能来解决较长的方块
- 预加载方块,可以在底部查看到下次出来的是那些方块,以便后续布局
- 迁徙动画,可以在顶部看到动物消除之后,对应的动物从左到右奔跑出来
核心架构
我将游戏分为四个核心模块,采用面向对象的设计思想:
GameState:游戏状态管理
负责维护游戏的所有状态数据,包括棋盘状态、分数、技能点数等。
GameRenderer:渲染系统
处理所有视觉相关的逻辑,包括方块渲染、动画效果等。
GameLogic:游戏逻辑
实现游戏的核心规则,包括消除判断、连击系统等。
GameController:控制层
处理用户输入,协调各个模块的协作。
GameSkill:技能输出
负责处理方块对应技能,确保每个方块输出的技能。
GameSound:音频管理
负责整个游戏的音频输出,可以设置背景音乐和点击音效等。
各模块代码结构清晰,各模块职责单一,用于后面维护和扩展。
开发中的设计和技术难点
设计
动物方块技能
每个长度的动物都有自己的设计,这里我主要说一下 boss 野牛 和 北极熊 设计。
作为BOSS级别的存在,野牛方块的消除机制和其他的消除不同,需要多次累计消除才能完全清除,而且越到后期野牛的出现几率就越大,解决野牛的最好办法就是使用北极熊冰冻技能 ,是控制场上所有的北极熊使移动回合暂停,让所有的 boss野牛 变的温顺变为一格!
技能点数系统
技能系统在游戏中也是非常重要的,根据游戏设计完整的技能点数积累机制。
javascript
// 技能状态管理
this.skill = {
currentPoints: 0, // 当前积累点数
maxPoints: 2500, // 点数上限
threshold: 1000, // 每个技能点需要的点数
skillPoint: 0 // 可用的技能点数
}
开局只有1000点的积分点,通过消除获取积分点得到技能点,超过1000积分累计一个技能,默认最大2500积分,超过积分不累计,直到第一次使用积分。使用积分,默认最大积分和第一积分点会累加。
核心逻辑,点击使用机会按钮后:
- 每个技能点需要的点数的阈值变为1500(即2500-1000)
- 点数上限的最大值变为3500(即2500+1000)
javascript
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隐藏,通过生成动画加载向上移动一行。
javascript
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()
函数创建生成长度权重来增加游戏难度和可玩性😄。
javascript
// 根据权重随机生成方块长度
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 设置百分百宽高加上动画帧就可以了。
javascript
.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
的初始函数。
javascript
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 的过程,在这个过程中使用函数实际上不是运动的,所以针对抖动需要添加帧动画的模式。

对 Tween
的缓动方法做了其他思路的改变,加上 keyframe
的实现,因为在本游戏中有位移为0,但是中间做的偏移动画,例如首图中方块元素消除之后的左右摆动,开始位置是0,结束也是0,所以在原始的 Tween
是不奏效的,针对这个问题做了如下改动。
javascript
// 根据关键帧计算当前值的核心函数
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
,非常自由。
javascript
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`
}
})
方块拖动与碰撞检测
在方块的拖动时要确保只能在有空隙的地方拖拽,所以需要做平滑拖动检测。由于动物方块的占格长度不同,传统的网格碰撞检测无法直接使用,这个方法确保了方块只能在空位上移动,不会与其他方块重叠,同时保证了拖动的流畅性。
我设计了一套基于位置预测的碰撞系统:
javascript
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
跳出循环。
javascript
// 应用重力(返回是否有方块掉落)
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,否则也要视为普通方块。
检测消除之后还需要通过积分系统关联积分累计,然后等所有动画完成之后,再需要重新重复上面一步的下落检测。
javascript
// 检查并执行消除(返回是否有消除发生)
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
则表示还有需要下落的方块或者需要消除的行。
javascript
// 处理游戏效果(掉落、消除等)
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链接
在线体验 :预览链接
注:本文仅分享技术学习经验,相关游戏素材和机制已进行差异化设计,如有侵权请联系删除