阅读 1078

Flutter 入门与实战(七十八):科学揭秘为什么在掘金抽奖总是抽了个寂寞

我正在参加中秋创意投稿大赛,详情请看:中秋创意投稿大赛

前言

前段时间看到不少掘友都在梭哈辛辛苦苦签到的矿石,想抽中掘金的“希尔顿月饼”,甚至不惜撸代码来加速抽奖,结果自然是——抽了个寂寞!我都怀疑那些提供自动抽奖脚本的是不是掘金的(大佬们求放过😜)。每天签到,攒够上万矿石也不容易,看似不要钱,但也是需要时间和持续坚持啊!本篇,岛上码农模拟抽奖来看看你的20000矿石来看看最后都能抽中个啥,再从科学的角度解密为什么你会抽了个寂寞。 抽奖

抽奖界面

首先是界面实现,九宫格布局,我们用 GridView 来做。这里我们把数量固定为9个,其中中间的一格为按钮。中间之前的奖品下标不变,中间之后的因为隔了一格,需要减1。

GridView.count(
  crossAxisCount: 3,
  crossAxisSpacing: 10.0,
  mainAxisSpacing: 20.0,
  childAspectRatio: 0.8,
  shrinkWrap: true,
  physics: NeverScrollableScrollPhysics(),
  children: List<Widget>.generate(
    9,
    (index) {
      int lotteryIndex =
          index < (lotteryController.lotteryCardNumber ~/ 2)
              ? index
              : index - 1;
      LotteryEntity lottery = lotteryController.lotteres[lotteryIndex];
      return index != (lotteryController.lotteryCardNumber ~/ 2)
          ? LotteryCard(
              name: lottery.name,
              cardAssetName: lottery.assetName,
              color: index ==
                          lotteryController.lotteryOrder[
                              lotteryController.currentIndex] &&
                      lotteryController.isRunning
                  ? Colors.green[200]!
                  : Colors.white,
            )
          : LotteryButton(
              name: '抽奖',
              onPressed: lotteryController.isRunning
                  ? null
                  : () {
                      lotteryController.startLottery();
                    },
            );
    },
  ),
),
复制代码

LotteryEntity是奖品实体类,只有三个属性,图片文件名称、奖品名称和中奖概率。这里中奖概率probability为整数,稍后我们讲怎么通过这个整数算出实际的概率。

class LotteryEntity {
  final String assetName;
  final String name;
  final int probability;

  LotteryEntity({
    required this.name,
    required this.assetName,
    required this.probability,
  });
}
复制代码

通过按顺时针方向依次改变卡片底色来实现看起来是不停移动的效果,因此当下标 index == lotteryController.lotteryOrder[lotteryController.currentIndex]且已经启动了抽奖(isRunningtrue)时,更改卡片的底色,否则使用白色。lotteryController.lotteryOrder是状态管理的九宫格移动次序表,实际也可以通过程序算出来,不过我们偷个懒,直接写个数组[0, 1, 2, 4, 7, 6, 5, 3]表示移动的次序。卡片和按钮的代码我们不贴了,可以直接到这里查看源码(lottery 目录):抽奖源码

抽奖业务逻辑

对于卡片的移动,我们使用定时器实现,每隔一定的时间就移动一格(调用_loopIndex方法)。中奖后开始慢慢降低速度(即_timerStep += 1,你懂的),直到转到中奖的位置,之后就是提醒中奖奖品。

void _rotateCard(Timer timer) {
  if (_rewardedIndex.value == -1) {
    _loopIndex();
  } else {
    if (_stepCounter++ > _timerStep) {
      _loopIndex();
      _stepCounter = 0;
      _timerStep += 1;
      if (_lotteryOrder[_currentIndex.value] == _rewardedIndex.value) {
        timer.cancel();
        _isRunning.value = false;
        _showReward();
      }
    }
  }
}

void _loopIndex() {
  _currentIndex.value++;
  if (_currentIndex.value == _lotteryOrder.length) {
    _currentIndex.value = 0;
  }
}

void _showReward() {
  Get.defaultDialog(
    title: '恭喜中奖!',
    titleStyle: TextStyle(color: Colors.orange[400]),
    content: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Image.asset(_lotteries[_rewardedIndex.value].assetName,
            width: 60, height: 60),
        Text(
          _lotteries[_rewardedIndex.value].name,
          style: TextStyle(
            color: Colors.orange[400],
          ),
        ),
      ],
    ),
  );
}
复制代码

这里需要注意下标转换,我们中奖的下标_rewardedIndex是奖品数组的下标,而移动的下标是九宫格中移动的次序,因此判断时是否等于需要将九宫格的转换为实际奖品数组的下标进行判断,即:_lotteryOrder[_currentIndex.value] == _rewardedIndex.value

抽奖逻辑

业务代码还是通过 GetX+定时器完成。根据掘友们勇于奉献的梭哈精神,我们可以知道中奖的概率大概是:66矿石:70%;Bug:29.99%。也就是其他的奖品概率不到万分之一,我们这里匀一点给其他奖品,把Bug 的降低一下,29.94%吧,其他的都是0.01%(万分之一,扎心了😂)。转为整数值就是下面的奖品数组:

_lotteries = [
    LotteryEntity(
      name: '66矿石',
      assetName: 'images/rock.png',
      probability: 7000,
    ),
    LotteryEntity(
      name: '掘金限量徽章',
      assetName: 'images/medals.png',
      probability: 1,
    ),
    LotteryEntity(
      name: '星巴克月饼',
      assetName: 'images/starbuck_mooncake.png',
      probability: 1,
    ),
    LotteryEntity(
      name: 'Bug',
      assetName: 'images/bug.png',
      probability: 2994,
    ),
    LotteryEntity(
      name: '字节中秋礼盒',
      assetName: 'images/bytedance_mooncake.png',
      probability: 1,
    ),
    LotteryEntity(
      name: '抖音探月月饼',
      assetName: 'images/tiktok_fly.png',
      probability: 1,
    ),
    LotteryEntity(
        name: '抖音中秋月饼',
        assetName: 'images/tiktok_mooncake.png',
        probability: 1),
    LotteryEntity(
      name: '希尔顿月饼',
      assetName: 'images/hilton_mooncake.png',
      probability: 1,
    ),
  ];
复制代码

接下来就是通过概率来计算中奖奖品。这里我们用到了一个数值区间来实现概率,即将随机得到的数值和区间比较,如果落在某个奖品的数值区间就算是该奖品中奖了,而这个数值区间的长度就是整数概率值 probability的值,如下图所示。构建的数值区间数组其实就是从2个元素开始,同一个下标的数值区间值为奖品概率值加上奖品的概率值,得到的数值区间数组为[7000, 7001, 7002, 9996, 9997, 9998, 9999, 10000],第一个元素不放入,若都没有命中就是第一个中奖了。

中奖区间

为了保持区间不重复判断,我们按数值区间从大到小判断,即从数值区间数组倒着来判断,且从倒数第2个开始(大于倒数第2个的就说明是最后1个奖品中奖)。这样可以看到排序后的相邻两个数值的间隔区间其实就能够真实反映每个奖品的中奖概率。 计算中奖奖品的代码如下:

void _updateRewardIndex(int rewardedValue) {
  var orderedLotteries = _lotteries.map((e) => e.probability).toList();
  // 构建递增的数值区间
  for (var i = 1; i < orderedLotteries.length; i++) {
    orderedLotteries[i] += orderedLotteries[i - 1];
  }
  // 默认第一个中奖
  var orderIndex = 0;
  for (var i = orderedLotteries.length - 2; i >= 0; --i) {
    // 如果随机数值落高于当前数值区间的起点值,则表示中奖奖品落入了该区间对应奖品的后一个奖品
    if (rewardedValue >= orderedLotteries[i]) {
      orderIndex = i + 1;
      break;
    }
  }

  _rewardedIndex.value = orderIndex;
}

复制代码

抽取奖品的代码如下:

Future.delayed(Duration(seconds: 3), () {
  // 求取区间的最大值(其实就是所有概率值求和)
  int maxRandomNum = _lotteries
      .map((e) => e.probability)
      .toList()
      .reduce((value, element) => value + element);
  var rewardedValue = Random().nextInt(maxRandomNum);
  _updateRewardIndex(rewardedValue);
});
复制代码

揭密:概率的游戏

好了,现在我们来算一下以上面说的概率,我们能够抽中除 bug 和矿石以外奖品的概率。每次抽奖都是独立的,假设我们抽 N 次,那么实际我们应该算不抽中大奖的概率值,再最后用1减去该值得到的就是我们中大奖的概率。每次不中大奖的概率为1-a,其中 a 为抽中 bug 和矿石这类小奖的概率,也就是99.94%,得到的公式如下:

中奖概率计算公式

假设你有20000矿石,每200矿石抽1次,可以抽100次,然后再算上抽中66矿石的补偿,就假设可以抽150次吧,那么中间概率就是:

中奖概率

也就是耗费20000矿石,抽150次,实际还不到9%的概率中大奖,我们来模拟一下。

void simulate(int times) {
  int bigReward = 0;
  var orderedLotteries = _lotteries.map((e) => e.probability).toList();

  for (var i = 1; i < orderedLotteries.length; i++) {
    orderedLotteries[i] += orderedLotteries[i - 1];
  }
  int maxRandomNum = _lotteries
      .map((e) => e.probability)
      .toList()
      .reduce((value, element) => value + element);
  for (int i = 0; i < times; i++) {
    var rewardedValue = Random().nextInt(maxRandomNum);
    var orderIndex = 0;
    for (var i = orderedLotteries.length - 2; i >= 0; --i) {
      if (rewardedValue >= orderedLotteries[i]) {
        orderIndex = i + 1;
        break;
      }
    }
    if (orderIndex != 0 && orderIndex != 3) {
      bigReward++;
    }
    print('第$i 次抽奖,奖品:${_lotteries[orderIndex].name}');
  }

  print(
      '中大奖次数:$bigReward,中大奖概率${(bigReward / times * 100).toPrecision(2)}%。');
}
复制代码

这里直接就循环150次来看看,只要中奖的不是矿石和 bug,我们就把中奖次数加1,然后看看中大奖的次数,实际结果很扎心,中奖次数为0

提高概率前

那为什么还有人中奖呢,说明之前大家梭哈的概率不对,以及大奖的概率并不是完全相等,我们调一下概率,把每个大奖的概率调到1%(提高了100倍的概率)看看怎么样,还不错,连抽150次,中了6次大奖,有4%的概率中奖。

提高概率后

根据大家梭哈的结果来看,实际概率应该是低于1%,但应该高于0.01%,但真实的中奖概率是多少,只有掘金的运营大佬们知道了。

总结

本篇通过模拟抽奖“揭秘”了掘金抽奖的概率游戏,仅供参考,大家可以自行决定是否要继续抽奖,反正我是不会抽的😎!

我是岛上码农,微信公众号同名,这是Flutter 入门与实战的专栏文章,提供体系化的 Flutter 学习文章。对应源码请看这里:Flutter 入门与实战专栏源码。如有问题可以加本人微信交流,微信号:island-coder

👍🏻:觉得有收获请点个赞鼓励一下!

🌟:收藏文章,方便回看哦!

💬:评论交流,互相进步!

文章分类
Android
文章标签