嘿!这真的是一个正经的抽奖程序!

小仙女   09月11, 2020

奇舞团有一个传统项目,每年年会由我在现场写一个抽奖程序,所有人一起review代码,以确保抽奖算法正确且公平,然后愉快滴开始抽奖。

现场写的抽奖程序不仅要公平无bug,而且还要有一定的趣味性,且不能和往年的重复。

2017年年会我写了一个随机抽纸牌中奖1的程序,而今年年会,我灵机一动????,决定写一个更有(不)趣(正)味(经)的抽奖程序。

长话短说,我们就着代码一步步往下看。

首先是常规随机洗牌三连:

function random(m, n) {

  return m + Math.floor(Math.random() * n);

}

function randomItem(arr, from = 0, to = arr.length) {

  const index = random(from, to);

  return {

    index,

    value: arr[index],

  };

}

function shuffle(arr) {

  for(let i = arr.length; i > 0; i--) {

    const {index} = randomItem(arr, 0, i);

    [arr[index], arr[i - 1]] = [arr[i - 1], arr[index]];

  }

  return arr;

}

上面的代码没有什么特别的,只是一个朴实的洗牌算法,额外封装两个随机函数,因为后面的代码中还要使用。对比一下,2017年版的代码更加“妖艳”:

function* generatePoker() {

  const points = ['A', 2, 3, 4, 5, 6, 7, 8, 9, 10, 'J', 'Q', 'K'];

  yield* points.map(p => ['♠️', p]);

  yield* points.map(p => ['♣️', p]);

  yield* points.map(p => ['♥️', p]);

  yield* points.map(p => ['♦️', p]);

}

const cards = generatePoker();

class PickedCards {

  constructor(key, storage = localStorage) {

    this.key = key;

    this.storage = storage;

    this.cards = JSON.parse(storage.getItem(key)) || [];

    this.cardSet = new Set(this.cards.map(card => card.join('')));

  }

  add(card) {

    this.cards.push(card);

    this.cardSet.add(card.join(''));

    this.storage.setItem(this.key, JSON.stringify(this.cards));

  }

  has(card) {

    return this.cardSet.has(card.join(''));

  }

  clear() {

    this.storage.clear();

  }
}

const pickedCards = new PickedCards('pickedCards');

function* shuffle(cards, pickedCards) {

  cards = [...cards];

  cards = cards.filter(card => !pickedCards.has(card));

  let len = cards.length;

  while(len) {

    const i = Math.floor(Math.random() * len);

    pickedCards.add(cards[i]);

    yield cards[i];

    [cards[i], cards[len - 1]] = [cards[len - 1], cards[i]];

    len--;
  }
}

2017版的随机扑克牌代码

有了上面朴实的随机代码,理论上我们就可以愉快滴抽奖了:

function random(m, n) {

  return m + Math.floor(Math.random() * n);

}

function randomItem(arr, from = 0, to = arr.length) {

  const index = random(from, to);

  return {

    index,

    value: arr[index],

  };

}

function shuffle(arr) {

  for(let i = arr.length; i > 0; i--) {

    const {index} = randomItem(arr, 0, i);

    [arr[index], arr[i - 1]] = [arr[i - 1], arr[index]];

  }

  return arr;

}

let members = ['胖虎', '强夫', '静香', '大雄', '哆啦A梦', '吕布', '张飞', '关羽', '刘备', '曹操', '孙权', '周瑜',

  '黄盖', '赵云', '吕蒙', '孙悟空', '猪八戒', '唐僧', '沙悟净', '光头强', '熊大', '熊二',

  '喜洋洋', '美羊羊', '红太狼', '灰太狼',

];

console.log(shuffle(members).slice(-3)); // 抽取3名获奖者

当然,我们不会只是这么无聊滴抽取,还是要玩点花样,不然怎么好意思说自己是前端呢?

HTML必须有:

<!DOCTYPE html>

<html>

<head>

  <meta charset="utf-8">

  <meta name="viewport" content="width=device-width">

  <title>一起抽奖吧</title>

  <link rel="stylesheet" href="style.css">

</head>

<body>

  <div id="control"><button id="start">开始</button><button id="clear">清空</button></div>

  <div id="track">

    <div><span class="horse"></span><span class="player">1</span></div>

    <div><span class="horse"></span><span class="player">2</span></div>

    <div><span class="horse"></span><span class="player">3</span></div>

    <div><span class="horse"></span><span class="player">4</span></div>

    <div><span class="horse"></span><span class="player">5</span></div>

    <div><span class="horse"></span><span class="player">6</span></div>

  </div>

  <script src="app.js"></script>

</body>

</html>

CSS简单写一个:

html, body {

  padding: 0;

  margin: 0;

  width: 100%;

  height: 100%;

}

#control {

  text-align: center;

  line-height: 120px;

}

#control button {

  font-size: 2rem;

  margin: 0 10px;

}

#track {

  max-width: 1250px;

  max-height: 500px;

  border-top: solid 1px #aaa;

}

#track div {

  position: relative;

  height: 100px;

  line-height: 100px;

  font-size: 1.5rem;

  padding: 0 10px;

  color: #aaa;

}

#track .player {

  float: right;

}

#track .horse {

  display: inline-block;

  font-size: 5rem;

  transform: scale(-1, 1);

  position: absolute;

  left: 850px;

  top: 0;

  z-index: 99999;

}

#track .horse span {

  display: inline-block;

  transform: scale(-1, 1);

}

#track div:nth-child(2n) {

  background: #666;

}

#track div::after {

  content: ' ';

  position: absolute;

  left: 930px;

  width: 20px;

  height: 100%;

  background: #333;

}

这个玩法呢,就是个跑马小游戏:

接下来,我们开始完善JS代码。为了记录抽奖结果和连续抽奖(每个人只能中一次奖),避免不小心刷新了页面,导致结果丢失,我们用localStorage存一下:

const prizeStorageKey = 'prize10';

function addResults(players) {

  const result = getResults();

  result.push(...players);

  localStorage.setItem(prizeStorageKey, result.join());

}

我们如果不小心刷新了页面,重新开始抽奖之前,我们要把已经中过奖的小伙伴从列表里剔除:

function getResults() {

  const result = localStorage.getItem(prizeStorageKey);

  return result ? result.split(',') : [];

}

function filterWinner(members) {

  const winners = new Set(getResults());

  return members.filter(m => !winners.has(m));

}

members = filterWinner(members);

然后我们可以点【开始】按钮抽奖,点【清除】按钮清除localStorage记录。

const startBtn = document.getElementById('start');

const clearBtn = document.getElementById('clear');

startBtn.addEventListener('click', async () => {

  startBtn.disabled = 'disabled';

  clearBtn.disabled = 'disabled';

  // 重新洗牌

  shuffle(members);

  // 取出最后6名同学,倒数3名中奖,剩下3名凑数

  const candidates = members.slice(-6).reverse();

  // 将中奖结果保存到localStorage中

  addResults(candidates.slice(0, 3));

  members.length -= 3;

  // 开始跑马程序

  await race(candidates);

  startBtn.disabled = '';

  clearBtn.disabled = '';

});

clearBtn.addEventListener('click', () => {

  // 清除所有中奖记录

  localStorage.removeItem(prizeStorageKey);

});

接下来就是实现关键的跑马程序了。

其实我们的中奖结果在跑马程序开始前就已经出来了,跑马程序只是运行动画效果,和中奖结果无关。

最简单的一种方式就是根据排名依次从短到长,生成跑马总时间,然后将6个人随机到不同的赛道开始跑马:

function race(candidates) {

  const durations = [];

  for(let i = 0, duration = 0.9; i < candidates.length; i++) {

    durations.push(duration);

    // 每一名次随机增加 0.02 ~ 0.05 的时间

    duration += random(2, 5) * 0.01;

  }

  const players = shuffle([...candidates.entries()]);
  ...
}

但是这样有个问题,就是跑马的时候,名次落后的时间长速度慢,名次靠前的速度快始终跑在前面,胜负毫无悬念,也就失去了赛马的意义。所以要做随机,以保留悬念。

产生随机有很多种方法,这里用一种最简单的方法,就是把一次比赛分成若干个小阶段,每个小阶段分配一个基准时间,但是允许每个选手在该阶段时间有一定的正负扰动。比如:

A选手跑完全程时间8秒钟,B选手跑完全程时间为10秒钟,我们第一阶段先取路程的1/4,A跑完1/4程的基准时间是2秒,B是2.5秒,假设扰动参数为正负0.5,那么A跑完1/4程的时间最多是2+0.5=2.5秒,而B跑完1/4程的时间最少是2.5-0.5=2.0秒,这样就有可能在前1/4赛程里A选手反而落后B选手了。多分几个赛程,就可以有足够的悬念。

我们先定义划分赛程的函数:

function partRace(durations, factor) {

  // 根据赛程总时间 duration 和 factor 来划分赛程

  // 赛程所用基准时间为 duration * factor,扰动 -0.1 ~ +0.1

  const subDuration = durations.map(d => d * factor * random(9, 11) / 10);

  subDuration.map((d, i) => {

    durations[i] -= d;

    return durations[i];

  });

  return subDuration;

}

这样我们把全程划分4段赛程:

function race(candidates) {

  const durations = [];

  for(let i = 0, duration = 0.9; i < candidates.length; i++) {

    durations.push(duration);

    // 每一名次随机增加 0.02 ~ 0.05 的时间

    duration += random(2, 5) * 0.01;

  }

  // 划分4段赛程

  const round1 = partRace(durations, 0.25);

  const round2 = partRace(durations, 0.33);

  const round3 = partRace(durations, 0.5);

  const round4 = durations.map(d => d + 0.1);
  ...
}

这里面还有一个小技巧,我们划分赛程的时候,给最后一轮留下10%的时间,这是为了避免前面几轮赛程积累的随机扰动使得最后一程的时间太短。

这样我们就可以绘制赛马动画了:

function partRace(durations, factor) {

  // 根据赛程总时间 duration 和 factor 来划分赛程

  // 赛程所用基准时间为 duration * factor,扰动 -0.1 ~ +0.1

  const subDuration = durations.map(d => d * factor * random(9, 11) / 10);

  subDuration.map((d, i) => {

    durations[i] -= d;

    return durations[i];

  });

  return subDuration;

}

function race(candidates) {

  const durations = [];

  for(let i = 0, duration = 0.9; i < candidates.length; i++) {

    durations.push(duration);

    // 每一名次随机增加 0.02 ~ 0.05 的时间

    duration += random(2, 5) * 0.01;

  }

  const players = shuffle([...candidates.entries()]);

  trackEl.innerHTML = players.map((p, i) => {

    return `<div>

 <span class="horse">${randomItem(['????', '????', '????', '????']).value}</span>

 <span class="player">${p[1]} ${i + 1}</span>

 </div>`;

  }).join('');

  // 划分4段赛程

  const round1 = partRace(durations, 0.25);

  const round2 = partRace(durations, 0.33);

  const round3 = partRace(durations, 0.5);

  const round4 = durations.map(d => d + 0.1);

  const results = ['????', '????', '????', '????', '????', '????'];

  const T = 8000;

  const horses = document.querySelectorAll('.horse');

  const promises = [];

  for(let i = 0; i < horses.length; i++) {

    const horse = horses[i];

    const idx = players[i][0];

    promises.push(raceHorse(horse, round1[idx] * T)

      .then(() => {

        return raceHorse(horse, round2[idx] * T, 30 + trackLen / 4);

      })

      .then(() => {

        return raceHorse(horse, round3[idx] * T, 30 + 2 * trackLen / 4);

      })

      .then(() => {

        return raceHorse(horse, round4[idx] * T, 30 + 3 * trackLen / 4);

      })

      .then(() => {

        horse.innerHTML = `<span>${results[idx]}</span>${horse.innerHTML}`;

        return raceHorse(horse, 0.1 * T, 30 + trackLen, 100);

      }));

  }

  return Promise.all(promises);
}

具体的raceHorse就是一个简单的DOM匀速动画绘制过程:

function raceHorse(horseEl, duration, from = 30, by = trackLen / 4) {

  return new Promise((resolve) => {

    const startTime = Date.now();

    requestAnimationFrame(function f() {

      let p = (Date.now() - startTime) / duration;

      p = Math.min(p, 1.0);

      horseEl.style.left = `${from + p * by}px`;

      if(p < 1.0) requestAnimationFrame(f);

      else resolve();

    });
  });
}

把完整的代码汇总一下:

function random(m, n) {

  return m + Math.floor(Math.random() * n);

}

function randomItem(arr, from = 0, to = arr.length) {

  const index = random(from, to);

  return {

    index,

    value: arr[index],

  };

}

function shuffle(arr) {

  for(let i = arr.length; i > 0; i--) {

    const {index} = randomItem(arr, 0, i);

    [arr[index], arr[i - 1]] = [arr[i - 1], arr[index]];

  }

  return arr;

}

const prizeStorageKey = 'prize10';

function getResults() {

  const result = localStorage.getItem(prizeStorageKey);

  return result ? result.split(',') : [];
}

function addResults(players) {

  const result = getResults();

  result.push(...players);

  localStorage.setItem(prizeStorageKey, result.join());

}

function filterWinner(members) {

  const winners = new Set(getResults());

  return members.filter(m => !winners.has(m));

}

let members = ['胖虎', '强夫', '静香', '大雄', '哆啦A梦', '吕布', '张飞', '关羽', '刘备', '曹操', '孙权', '周瑜',

  '黄盖', '赵云', '吕蒙', '孙悟空', '猪八戒', '唐僧', '沙悟净', '光头强', '熊大', '熊二',

  '喜洋洋', '美羊羊', '红太狼', '灰太狼',

];

members = filterWinner(members);

const startBtn = document.getElementById('start');

const clearBtn = document.getElementById('clear');

startBtn.addEventListener('click', async () => {

  startBtn.disabled = 'disabled';

  clearBtn.disabled = 'disabled';

  // 重新洗牌

  shuffle(members);

  // 取出最后6名同学,倒数3名中奖,剩下3名凑数

  const candidates = members.slice(-6).reverse();

  // 将中奖结果保存到localStorage中

  addResults(candidates.slice(0, 3));

  members.length -= 3;

  // 开始跑马程序

  await race(candidates);

  startBtn.disabled = '';

  clearBtn.disabled = '';

});

clearBtn.addEventListener('click', () => {

  // 清除所有中奖记录

  localStorage.removeItem(prizeStorageKey);

});

const trackLen = 820; // 205 * 4

const trackEl = document.getElementById('track');

function partRace(durations, factor) {

  // 根据赛程总时间 duration 和 factor 来划分赛程

  // 赛程所用基准时间为 duration * factor,扰动 -0.1 ~ +0.1

  const subDuration = durations.map(d => d * factor * random(9, 11) / 10);

  subDuration.map((d, i) => {

    durations[i] -= d;

    return durations[i];

  });

  return subDuration;
}

function race(candidates) {

  const durations = [];

  for(let i = 0, duration = 0.9; i < candidates.length; i++) {

    durations.push(duration);

    // 每一名次随机增加 0.02 ~ 0.05 的时间

    duration += random(2, 5) * 0.01;

  }

  const players = shuffle([...candidates.entries()]);

  trackEl.innerHTML = players.map((p, i) => {

    return `<div>

 <span class="horse">${randomItem(['????', '????', '????', '????']).value}</span>

 <span class="player">${p[1]} ${i + 1}</span>

 </div>`;

  }).join('');

  // 划分4段赛程

  const round1 = partRace(durations, 0.25);

  const round2 = partRace(durations, 0.33);

  const round3 = partRace(durations, 0.5);

  const round4 = durations.map(d => d + 0.1);

  const results = ['????', '????', '????', '????', '????', '????'];

  const T = 8000;

  const horses = document.querySelectorAll('.horse');

  const promises = [];

  for(let i = 0; i < horses.length; i++) {

    const horse = horses[i];

    const idx = players[i][0];

    promises.push(raceHorse(horse, round1[idx] * T)

      .then(() => {

        return raceHorse(horse, round2[idx] * T, 30 + trackLen / 4);

      })

      .then(() => {

        return raceHorse(horse, round3[idx] * T, 30 + 2 * trackLen / 4);

      })

      .then(() => {

        return raceHorse(horse, round4[idx] * T, 30 + 3 * trackLen / 4);

      })

      .then(() => {

        horse.innerHTML = `<span>${results[idx]}</span>${horse.innerHTML}`;

        return raceHorse(horse, 0.1 * T, 30 + trackLen, 100);

      }));

  }

  return Promise.all(promises);

}

function raceHorse(horseEl, duration, from = 30, by = trackLen / 4) {

  return new Promise((resolve) => {

    const startTime = Date.now();

    requestAnimationFrame(function f() {

      let p = (Date.now() - startTime) / duration;

      p = Math.min(p, 1.0);

      horseEl.style.left = `${from + p * by}px`;

      if(p < 1.0) requestAnimationFrame(f);

      else resolve();

    });

  });

}

最终的效果:

以上就是今年奇舞团年会的抽奖程序,大家有什么想法可以关注我们的GitHub仓库2与我们交流。

2019年年会的抽奖程序我也已经想好了,只会更加有趣,不过暂时不能透露????,2019年年会继续加油~

文内链接: https://github.com/75team/raffle/tree/master/2017 https://github.com/75team/raffle