跳到教程内容
教程首页
App Inventor 2 教程 石头剪刀布
准备开始
动手创造 · PROJECT LAB

石头剪刀布

准备探索你的新作品
0%
★★★ 项目难度每一步,都离作品更近

先了解你将做出什么

应用介绍

人人都会玩石头剪刀布,但你有没有想过:能不能让手机学会你的出招习惯,然后专门克制你?

这个App不用任何现成的智能组件,只用列表、过程和一点点数学,就能做出一个越玩越聪明的对手。 你出得越多,它赢得越多——因为人是很难做到真正随机的,而机器最擅长发现规律。

界面示意图

学完这一篇,你会真正理解一个朴素但通用的思路:记录历史 → 找出规律 → 预测下一步

石头剪刀布教程(难度系数:★★★)

游戏基本逻辑设计

  1. 玩家点「石头」「剪刀」「布」三个按钮之一出招。
  2. 电脑先出招(不能偷看玩家这次出什么,否则就是作弊),然后双方亮牌、判胜负、加分。
  3. 电脑把玩家「上一手 → 这一手」的转换次数记在一张表里,下次就用这张表预测玩家,反过来克制。

界面已经在模板工程里搭好了,组件名如下,后面写代码要用到:

组件名 类型 作用
按钮_石头 按钮_剪刀 按钮_布 按钮 玩家出招
标签_玩家分 标签_电脑分 标签 双方比分
标签_玩家选择 标签_电脑选择 标签 本轮出招的文字
图像_玩家选择 图像_电脑选择 图像 本轮出招的图片
标签_胜负 标签 本轮赢家
标签_矩阵第1行 标签_矩阵第2行 标签_矩阵第3行 标签 显示那张”学习表”
按钮_重置 按钮 清零重来

组件树示意图

素材是三张手势图,工程里已经上传好了:

石头

机器是怎么“学”的:转移矩阵

先不写代码,把原理搞明白。

电脑要记的东西其实特别简单:你上一手出了什么,接着这一手又出了什么,这种组合出现过几次。 把它记成一张 3×3 的表,行是”上一手”,列是”这一手”:

 
7 9 13
2 4 11
15 3 8

怎么读这张表:

  • 第 1 行第 3 列是 13,意思是”出了石头之后,接着出布“这件事发生过 13 次。
  • 第 2 行第 3 列是 11,意思是”出了剪刀之后接着出布”发生过 11 次。
  • 第 3 行第 1 列是 15,意思是”出了布之后接着出石头”发生过 15 次。

这张表有个正经名字,叫马尔可夫转移矩阵(Markov Transition Matrix), 以俄国数学家马尔可夫命名。 名字唬人,本质就是”记账”。

练一练:矩阵怎么更新

一开始表里全是 0:

 
0 0 0
0 0 0
0 0 0

假设玩家依次出了:石、布、布、剪。这时表会变成什么样?

关键是两两一组地看,不是一个一个看:

  • 石、布)、布、剪
  • 石、(布、布)、剪
  • 石、布、(布、剪

每一组就是”行、列”,对应格子加 1。

  • (石、布)→ 第 行第 列加 1
  • (布、布)→ 第 行第 列加 1
  • (布、剪)→ 第 行第 列加 1
 
0 0 1
0 0 0
0 1 1

看明白了吧——这张表就是玩家连续两手习惯的流水账。

用矩阵做出聪明的选择

有了流水账,电脑怎么用它?回到刚才那张表:

 
7 9 13
2 4 11
15 3 8

假设玩家上一手出的是石头,那就看第 1 行:出石头之后,接着出石头 7 次、出剪刀 9 次、出布 13 次。 13 最大,所以电脑判断:你这次最可能出布

那电脑该出什么?出剪刀——剪刀克布。

这就是全部的”智能”了。没有神经网络,没有训练,就是查表找最大值。 但只要你的出招有一丁点规律,它就能揪出来。

准备工作:给三个选项编号

代码里用数字代表三种出招,比用文字方便得多——数字可以直接当列表下标用

约定:石头 = 1剪刀 = 2布 = 3

global 石头 = 1
global 剪刀 = 2
global 布 = 3

global 选项 = []
global 克制 = []
global 选项文字 = []
global 选项图片 = []

global 转移矩阵 = []
global 上次出招 = 0
global 本次出招 = 0
global 电脑出招 = 0

其中最需要琢磨的是 克制 这个列表。它的设计很巧妙: 克制 的第 i 项,就是能赢 选项 第 i 项的那一手。

下标 选项 克制 读作
1 石头 布克石头
2 剪刀 石头 石头克剪刀
3 剪刀 剪刀克布

有了这个列表,”谁赢”和”该出什么”都不用写一堆 if 了,查一下表就有答案。

初始化矩阵、显示矩阵

两个过程。初始化矩阵 把 3×3 全部填 0;显示矩阵 把三行分别写到三个标签上。

procedure 初始化矩阵() {
  转移矩阵 = [[0, 0, 0], [0, 0, 0], [0, 0, 0]]
}

procedure 显示矩阵() {
  标签_矩阵第1行.Text = select(转移矩阵, 1)
  标签_矩阵第2行.Text = select(转移矩阵, 2)
  标签_矩阵第3行.Text = select(转移矩阵, 3)
}

注意:转移矩阵 是一个列表的列表select(转移矩阵, 1) 取出来的是第一行(还是个列表)。 把列表直接设给标签时,App Inventor 会自动加上小括号显示成 (0 0 0)

屏幕初始化

App 一启动,把四个列表和矩阵都准备好。

when Screen1.Initialize() {
  初始化矩阵()
  显示矩阵()
  选项 = [石头, 剪刀, 布]
  克制 = [布, 石头, 剪刀]
  选项文字 = ["石头", "剪刀", "布"]
  选项图片 = ["shitou.png", "jiandao.png", "bu.png"]
  上次出招 = 石头
}

注意点两个:

  1. 选项克制 这些列表不能在最开头声明变量时就赋值,因为那时候 石头剪刀 这三个变量还没初始化完。放到屏幕初始化里赋值就没问题了。
  2. 最后一句 上次出招 = 石头 是个小技巧。随便指定一个初始值,玩家第一次出招后就能立刻更新矩阵, 否则得等玩满两轮才有”上一手”,代码会复杂不少。

电脑怎么出招

开局没有任何历史数据,只能随机。之后就查表。

function 行内最大值(行) {
  return max(select(行, 1), select(行, 2), select(行, 3))
}

function 最可能的下一手() {
  return indexof(select(转移矩阵, 上次出招), 行内最大值(select(转移矩阵, 上次出招)))
}

function 随机出招() {
  return randomitem(选项)
}

function 聪明出招() {
  return select(克制, 最可能的下一手())
}

最可能的下一手 是整个App的核心,拆开看就三步:

  1. select(转移矩阵, 上次出招) —— 按上一手取出对应的那一行。
  2. 行内最大值(...) —— 找出这一行里最大的数。
  3. indexof(行, 最大值) —— 这个最大值在第几列,答案就是几。

聪明出招 只有一句:拿”最可能的下一手”当下标去查 克制 表。 克制表设计得好,这里就只要一行。

max 块默认只有两个插口,点击块左上角的蓝色齿轮可以加到三个。 矩阵每行固定三个数,所以三个插口刚好够用。

如果以后想扩展成”石头剪刀布蜥蜴斯波克”(5 种手势),这里就得改成循环遍历整行了。

判断胜负

有了 克制 表,判胜负一句话就够:电脑出的正好是玩家这手的克星,电脑就赢。

procedure 更新比分() {
  if 电脑出招 = select(克制, 本次出招) {
    标签_电脑分.Text = 标签_电脑分.Text + 1
    标签_胜负.Text = "电脑"
  } else if 本次出招 = select(克制, 电脑出招) {
    标签_玩家分.Text = 标签_玩家分.Text + 1
    标签_胜负.Text = "你"
  } else {
    标签_胜负.Text = "平局"
  }
}

更新矩阵

玩家出完这一手,把「上一手 → 这一手」那个格子加 1。

procedure 更新矩阵() {
  listreplace(select(转移矩阵, 上次出招), 本次出招,
              select(select(转移矩阵, 上次出招), 本次出招) + 1)
  显示矩阵()
}

注意:select(转移矩阵, 上次出招) 取出来的那一行,和矩阵里的那一行是同一个列表 (列表在 App Inventor 里是按引用传递的),所以直接对它 listreplace,矩阵本体就跟着变了, 不用再写回去。这是列表嵌套时很容易搞错的一个点。

把一轮串起来

三个按钮做的事情完全一样,只是传进来的出招不同,所以抽成一个过程。

procedure 走一轮(玩家这次) {
  if 本次出招 = 0 {
    电脑出招 = 随机出招()
  } else {
    电脑出招 = 聪明出招()
  }
  标签_电脑选择.Text = select(选项文字, 电脑出招)
  图像_电脑选择.Picture = select(选项图片, 电脑出招)

  本次出招 = 玩家这次
  标签_玩家选择.Text = select(选项文字, 本次出招)
  图像_玩家选择.Picture = select(选项图片, 本次出招)

  更新比分()
  更新矩阵()
  上次出招 = 本次出招
}

when 按钮_石头.Click() {
  走一轮(石头)
}

when 按钮_剪刀.Click() {
  走一轮(剪刀)
}

when 按钮_布.Click() {
  走一轮(布)
}

顺序非常关键:一定要先让电脑出招,再把 本次出招 设成玩家这次的选择。 如果反过来,电脑就”看见”了玩家这一手,那就是作弊了——虽然赢得更爽,但学不到东西。

重新开始

when 按钮_重置.Click() {
  初始化矩阵()
  显示矩阵()
  标签_玩家分.Text = 0
  标签_电脑分.Text = 0
  标签_胜负.Text = "无"
  标签_玩家选择.Text = "无"
  标签_电脑选择.Text = "无"
  上次出招 = 石头
  本次出招 = 0
}

本次出招 = 0 这句别漏了——它是”新的一局刚开始”的标记,走一轮 靠它决定第一手要随机出。

开始测试

连上AI伴侣,好好玩几局,重点验证这几件事:

  1. 点按钮能正常出招,双方图片和文字都对得上。
  2. 每点一次,矩阵里对应的格子加 1,显示同步更新。
  3. 比分加得对,平局不加分。
  4. 一直出石头,看电脑几轮之内学会一直出布。
  5. 试试有规律的循环:石、布、石、布…… 或者 石、剪、布、石、剪、布……,看它多久能跟上。

既然你知道电脑是”查上一手对应那行的最大值”,你就能预判它要出什么,然后专门克制它。

比如矩阵显示你出石头之后最常出布,那电脑这次一定出剪刀——你这时出石头就赢了。

  • 用这个办法你能连赢多少局?
  • 一直这么玩下去,矩阵会变成什么样?为什么它又会开始赢你?

后记

恭喜,你刚刚亲手写出了一个会从经验里总结规律的程序。

它的策略是人事先设计好的,只对石头剪刀布这一个问题管用,谈不上有多聪明。 但”记录历史、发现规律、预测下一步”这个套路本身用途极广,值得记住。

几个可以继续挖的方向:

  • 加个开关把矩阵藏起来,让电脑的”心思”不被玩家看到,游戏立刻公平很多。
  • 换个学习策略:现在只看”上一手”,能不能看”上两手”?矩阵要变成几维? (提示:3×3 会变成 9×3,数据量大了但更准。)
  • 想想看:如果不告诉程序”该查最大值”,它能不能自己摸索出策略?这正是更高级的算法要解决的问题。

Q:为什么一开始电脑总是输?

答:因为矩阵全是 0,最可能的下一手 只能取到第一个最大值(下标 1,也就是石头), 这时它的判断毫无根据。玩上十几轮,数据攒够了,它就开始变凶了——这就是”训练”