做一个石头剪刀布小游戏

最近我无意间看到一个非常有趣的小游戏,玩法看起来简单却颇有意思。它不是传统意义上的石头剪刀布,而是把这种经典的博弈规则搬到屏幕上,变成了一场群体之间的"进化战争"。石头、剪刀和布不再只是单一的手势,而是成群结队的小兵,它们在屏幕上乱跑,彼此相遇时遵循石头剪刀布的规则来吞并对方,逐渐演化出谁能统治整个画布的局面。第一次看到的时候,我就被这种群体对抗的视觉效果吸引住了,心里也萌生了一个念头:我是不是可以自己实现一个这样的游戏,并且在这个过程中做一些个性化改造?

最初我想的很简单,就是在屏幕上生成一些石头、剪刀和布,用 emoji 表情代替素材,毕竟这样省去了加载图片的麻烦,而且在现代浏览器里表情符号的兼容性已经相当不错。但我很快就发现,有些表情符号在部分浏览器环境下可能并不能正常显示,于是我也准备了退路,如果遇到显示问题,我会考虑换成其他符号或者干脆画一些简化的几何图形。不过幸运的是,在我的环境下石头(✊)、剪刀(✌️)和布(✋)都能正常展示,这让我可以毫无心理负担地继续推进。

真正让我觉得要动手做的一个原因是,我不满足于原本那个版本的"单个控制"。很多实现只是让你选中某一个棋子去移动,而这种设计的问题很明显:一旦你控制的那个棋子被对方吃掉,游戏体验就会戛然而止。这显然和我想要的群体策略对抗完全不一样。于是我决定让玩家控制的是一个"阵营",比如你选择剪刀,那么整个剪刀军团都会响应你的操作,整体朝某个方向缓慢移动,而不是操控某个单体单位。这样一来,游戏体验就完全不同了,你不是某个兵卒,而是整个军团的将军。

在开始编程之前,我习惯性地先把这个游戏的逻辑梳理了一遍,用一个简单的流程图来帮助我理清思路。整个游戏的循环大致是这样的:首先生成随机分布的石头、剪刀和布单位;然后不断进行更新,每个单位都会根据速度移动;玩家可以通过输入来调整自己阵营的整体趋势;当两个不同阵营的单位接触时,按照石头剪刀布的规则来决定胜负,败者被转化为胜者阵营;不断循环下去,直到某个阵营统治全场。

在脑子里理顺了逻辑之后,我就开始着手写代码。我选择用 HTML + JavaScript + Canvas 来实现,原因很简单:这种组合足够轻量,不需要复杂的构建工具,写出来的东西直接放到浏览器里就能运行,调试和修改都很方便。而且 Canvas 天生就适合绘制大量的小元素并不断刷新,刚好契合这个项目的需求。

我首先搭建了一个最基本的框架:在页面里放一个全屏的 <canvas>,然后通过 JavaScript 获取上下文对象。接着我写了一个实体类 Entity 来代表游戏中的每个小兵,它包含 type(石头、剪刀或布)、坐标 xy、速度 vxvy,还有一个绘制方法 draw,用来把对应的 emoji 渲染到画布上。这个阶段的目标只是让屏幕上出现一些随机分布的表情符号,能动起来就算成功。

写完之后我运行了一下,果然屏幕上出现了一堆 ✊✋✌️,它们各自朝着随机方向移动,看起来像一群小精灵在乱舞。不过很快问题就来了,这种随机直线运动实在是太生硬了,每个单位看起来像一颗子弹,直来直去,完全没有那种"漂移"的丝滑感。于是我意识到,需要在移动逻辑上做改造。具体来说,我不能让它们每一帧都沿着固定方向移动固定距离,而应该引入一种"目标趋势"的概念。比如说玩家输入了向右移动的指令,那么我给整个阵营设定一个目标速度向量,而当前速度会逐渐逼近这个目标速度,这样就会产生一种缓动效果,画面看上去更自然。

我把这个逻辑写进了更新函数里,大致的公式是这样的: vx = vx * 0.9 + targetVx * 0.1 vy = vy * 0.9 + targetVy * 0.1 这样一来,当前速度会在每一帧都逐渐贴近目标速度,而不会立即跳过去,从而实现类似漂移的感觉。等我重新跑一遍,果然画面舒服了很多,每个单位的运动不再是死板的直线,而是带有惯性,移动过程有一种柔和的流畅感。

当基本的移动效果做出来之后,我开始考虑玩家控制逻辑。我决定采用"阵营整体控制"的方式,也就是当玩家选择了某个阵营,比如剪刀,那么在键盘上按方向键或者在手机上滑动,就能让整个剪刀军团一起调整方向。这部分实现的关键在于给每个属于该阵营的单位施加一个额外的速度偏移,让它们整体向某个方向漂移。至于其他阵营,它们则保持原有的随机运动,不受玩家影响。这样一来,玩家就能感受到带领军团作战的快感,而不是局限于一个小兵的命运。

代码里我写了一个全局变量 playerFaction 来记录玩家阵营,值可以是 "rock", "scissors", "paper"。在初始化的时候,玩家可以选择控制哪一方。然后我在键盘事件监听函数里检测方向键,如果按下了右方向键,就把该阵营的 targetVx 设为正数;如果是上方向键,就把 targetVy 设为负数。移动端我也准备了一个方案,可以通过监听触摸事件,根据手指滑动方向来设定偏移。这样就实现了 PC 和移动端的双重适配。

接下来最核心的部分就是碰撞检测与规则判定了。逻辑很清晰:如果一个石头和一个剪刀碰撞,那么剪刀会变成石头;如果一个剪刀和一张布碰撞,那么布会变成剪刀;如果一张布和一个石头碰撞,那么石头会变成布。为了让这一过程更直观,我又画了一个规则图:

我在代码里写了一个函数 beats(a, b),用来判断 a 是否能战胜 b,返回布尔值。然后在每一帧更新时,我遍历所有单位,检测它们是否彼此接触,如果是,就调用规则函数来决定结果,最后把败者的 type 改成胜者的类型。这种"转化"效果看起来非常有趣,画面里会出现不断扩张和收缩的阵营,像是生物之间的捕食与进化。


当我把石头剪刀布的碰撞规则写完以后,屏幕上的局面终于开始"活"了起来。每一帧更新时,我看到不同阵营的小兵在不断地吞并和被吞并,颜色和符号在画布上动态变化。第一次看到石头群体一口气吞掉一片剪刀的时候,我甚至有点小激动,因为这种变化是完全由简单的规则驱动出来的复杂动态,而我只是提供了最基本的框架。

不过很快我就发现了一些问题。第一个问题是性能瓶颈。因为在最初的实现里,我用了一个双重循环去检测所有单位之间的碰撞,这意味着如果我有 200 个单位,就需要进行接近 200 × 200 次检测,而这是一个平方级的复杂度。虽然在低数量下没什么问题,但当我尝试把单位数量提升到 500 以上的时候,帧率明显下降,画面开始变得卡顿。我立刻意识到必须优化碰撞检测逻辑。

我想到的解决办法是"空间划分"。与其让所有单位都两两比较,不如把画布划分为一个个小格子,每个单位只需要和同格子或相邻格子的单位进行比较。这样就能大大减少不必要的检测次数,因为相隔很远的单位根本不可能发生碰撞。我在代码里实现了一个简单的网格系统:比如把整个画布分成 50×50 的小块,每个单位根据自己的位置放到对应的桶里,然后只检查桶里的元素。这样优化之后,即使单位数提升到 1000,帧率依然能保持流畅。

我用一张图把这个"空间划分"的思想画了出来,方便以后给别人解释:

第二个问题是边界处理。在没有做边界限制之前,小兵们会很快跑到画布之外,结果整个游戏的画面越来越空,直到什么也看不到。这显然不符合预期,于是我决定让所有单位在碰到边界时"反弹"。实现也很简单:如果 x 超过画布宽度或小于零,就把 vx 取负;如果 y 超过画布高度或小于零,就把 vy 取负。这样一来,所有小兵就被限制在了画布内,不会消失掉。不过我又觉得反弹的效果太过生硬,像是台球一样。我后来调整了一下,把速度乘上一个衰减系数,这样在撞墙时会略微减速,看起来更自然一些。

接着我考虑了游戏的"终局"问题。如果任由系统无限运行下去,很可能会形成一种僵持状态,但通常情况是某一个阵营逐渐吞并掉所有对手。我希望能在某一方统治全场的时候给出一个提示,宣布获胜。于是我写了一个统计函数,每隔一定帧数就数一遍画布上三种类型的单位数量。如果有一方数量达到总数的 95% 以上,我就宣布该阵营获胜,并弹出一个简单的提示框。这样玩家就能明确知道游戏什么时候结束,而不是永远盯着屏幕。

随着逻辑越来越完整,我开始想要给整个项目写一份更清晰的结构设计。我画了一个比较精致的系统架构图,概括了游戏循环和各个模块的关系:

写到这里,我突然想起之前在设计"漂移效果"的时候只给玩家阵营实现了缓动,但其他阵营依然是随机直线移动的。这样看久了就觉得有点别扭,因为玩家阵营在优雅地漂移,而其他小兵却还在傻乎乎地直来直去。我于是干脆把缓动逻辑推广到所有单位,让它们在每一帧都稍微朝着一个目标速度靠拢。区别在于玩家阵营的目标速度由输入决定,而其他阵营的目标速度则是每隔一段时间随机改变一次。这样一来,整个画面都呈现出流畅的漂移动作,看起来非常协调。

在这个过程中我踩了一个小坑,就是在移动端的适配上。键盘控制在 PC 上完全没问题,但在手机上显然没人会带着方向键。我原本考虑用屏幕上的虚拟摇杆,后来觉得实现起来稍微复杂一些,于是先用了一种简化方案:监听触摸事件,根据手指滑动的方向来决定目标速度。如果玩家从左往右滑,就给阵营一个向右的目标速度;如果从上往下滑,就让阵营整体下移。虽然不如虚拟摇杆精准,但对于休闲小游戏来说已经足够了。等我调试好以后,用手机点开网页,也能轻松玩起来,那种"随时随地开一局"的感觉让我挺满足的。

到这个阶段,核心玩法和主要功能已经全部实现,我决定开始对代码进行一些整理。最重要的是把不同部分的逻辑分开,避免写成一团乱麻。我给每个模块起了单独的函数:

  • updateEntities() 用来更新位置和速度;
  • handleCollisions() 专门负责碰撞检测与规则判定;
  • render() 负责绘制画布;
  • checkVictory() 负责胜负检测;
  • handleInput() 负责处理键盘和触摸输入。

这样一来,整个代码就变得结构清晰,任何时候想修改某个功能,都可以直接去找对应的函数,而不是在上千行的循环里苦苦翻找。

我还给 Entity 类加了一些辅助方法,比如 move()changeType(newType),前者封装了速度和位置的更新,后者则用来在碰撞时改变阵营。这样每个实体的行为就相对独立,而不是完全依赖外部函数去操控。

在整理的过程中,我也顺便加了一些小的美化,比如在画布左上角显示当前三种阵营的数量,让玩家随时能看到局势的变化。我甚至加了一个小动画,当某个阵营被吞并到只剩下个位数的时候,数字会闪烁,营造一种紧张感。这些都是锦上添花的功能,但对于游戏体验来说确实有加分。

写到这里,我已经有了一个完整可玩的"石头剪刀布群体战争",无论是在 PC 还是手机上,都能打开网页直接玩。整个开发过程让我非常享受,因为它并不是一个庞大的工程,却能在很短的时间内给我带来即时的反馈和成就感。尤其是当我解决了漂移效果和群体控制的难题以后,画面一下子从生硬变得灵动,这种转变特别直观,就像给游戏注入了生命一样。

相关推荐
用户21411832636023 小时前
dify插件开发-Dify 插件如何顺利上架应用市场?流程 + 常见问题一次讲透
前端
繁依Fanyi3 小时前
从零到一,制作一个项目展示平台
前端
给月亮点灯|3 小时前
Vue基础知识-重要的内置关系:vc实例.__proto__.__proto__ === Vue.prototype
前端·vue.js·原型模式
yuehua_zhang4 小时前
uni app 的app 端调用tts 进行文字转语音
前端·javascript·uni-app
再努力"亿"点点5 小时前
炫酷JavaScript鼠标跟随特效
开发语言·前端·javascript
前端达人5 小时前
从 useEffect 解放出来!异步请求 + 缓存刷新 + 数据更新,React Query全搞定
前端·javascript·react.js·缓存·前端框架
JIE_7 小时前
👨面试官:后端一次性给你一千万条数据,你该如何优化渲染?
前端
定栓7 小时前
vue3入门- script setup详解下
前端·vue.js·typescript
Json_Lee7 小时前
每次切项目都要改 Node 版本?macOS 自动化读取.nvmrc,解放双手!
前端