使用原生JS和CSS打造浏览器游戏:从零构建国旗猜谜游戏

本文详细介绍了如何使用纯原生JavaScript和CSS构建一个完整的浏览器游戏,包含数据结构处理、DOM操作、CSS动画、模块化编程等核心技术,无需任何外部库或框架。

使用原生JS和CSS打造浏览器游戏

如今进行Web开发可能会让人感到不知所措。有几乎无限丰富的库和框架可供选择。

在编写任何代码之前,你可能还需要实现构建步骤、版本控制和部署流水线。不如来一个有趣的建议?让我们退后一步,提醒自己现代JavaScript和CSS在没有闪亮附加功能的情况下是多么简洁和强大。

感兴趣吗?那就跟我一起,踏上仅使用原生JS和CSS制作浏览器游戏的旅程。

游戏概念

我们将构建一个国旗猜谜游戏。玩家会看到一个国旗和一个多项选择式的答案列表。

第一步:基本结构

首先,我们需要一个国家和各自国旗的列表。幸运的是,我们可以利用表情符号的力量来显示国旗,这意味着我们不需要自己寻找或创建它们。我已经以JSON格式准备了这些。

最简单的界面将显示一个国旗表情符号和五个按钮:

使用CSS网格居中所有内容,并使用相对大小,以便从最小屏幕到最大显示器都能良好显示。

现在获取我们的启动填充代码,我们将在整个教程中基于此进行构建。

我们的项目文件结构如下:

1
2
3
4
5
6
7
8
9
step1.html
step2.html // 等等
js/
  data.json
  // 更多js文件
helpers/
  // 我们将在第三步中处理
css/
i/

在每个部分的末尾,都会有当前状态代码的链接。

第二步:简单原型

让我们开始吧。首先,我们需要获取data.json文件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
async function loadCountries(file) {
  try {
    const response = await fetch(file);
    return await response.json();
  } catch (error) {
    throw new Error(error);
  }
}

// data.json包含一个对象数组,看起来像这样:
// { flag: <国旗表情符号>, name: <国家名称>}
loadCountries('./js/data.json')
.then((data) => {
    startGame(data.countries)
});

现在我们有了数据,可以开始游戏了。以下代码有大量注释。花几分钟阅读并理解发生了什么。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function startGame(countries) {
  // 我们需要一个随机化的国家列表
  // 首先,从JSON对象获取所有键
  // 现在洗牌
  shuffle(countries);

  // 选择答案
  let answer = countries.shift();

  // 再选择4个国家,合并我们的答案并洗牌
  let selected = shuffle([answer, ...countries.slice(0, 4)]);

  // 更新DOM,从国旗开始
  document.querySelector('h2.flag').innerText = answer.flag;
  // 用国家名称更新每个按钮
  document.querySelectorAll('.suggestions button')
      .forEach((button, index) => {
    const countryName = selected[index].name;
    button.innerText = countryName;
    // 这将允许我们检查点击的按钮是否对应答案
    button.dataset.correct = (countryName === answer.name);
    button.onclick = checkAnswer;
  })
}

以及一些检查答案的逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function checkAnswer(e) {
  const button = e.target;
  if (button.dataset.correct === 'true') {
    button.classList.add('correct');
    alert('正确!做得好!');
  } else {
    button.classList.add('wrong');
    alert('答案错误,请重试');
  }
}

你可能已经注意到我们的startGame函数调用了一个shuffle函数。这里是Fisher-Yates算法的简单实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// Fisher-Yates洗牌算法
// https://bost.ocks.org/mike/shuffle/
function shuffle(array) {
  var m = array.length, t, i;

  // 当还有元素需要洗牌时...
  while (m) {

    // 选择一个剩余元素...
    i = Math.floor(Math.random() * m--);

    // 并与当前元素交换
    t = array[m];
    array[m] = array[i];
    array[i] = t;
  }

  return array;
}

第三步:添加类

是时候进行一些整理了。现代库和框架通常强制使用某些约定,帮助为应用程序应用结构。随着事物开始增长,这是有意义的,将所有代码放在一个文件中很快就会变得混乱。

让我们利用模块的力量来保持代码的模块化。更新你的HTML文件,用以下内容替换内联脚本:

1
<script type="module" src="./js/step3.js"></script>

现在,在js/step3.js中,我们可以加载我们的助手:

1
2
import loadCountries from "./helpers/loadCountries.js";
import shuffle from "./helpers/shuffle.js";

确保将shuffle和loadCountries函数移动到它们各自的文件中。

注意:理想情况下,我们也会将data.json作为模块导入,但不幸的是,Firefox不支持导入断言。

你还需要在每个函数前添加export default。例如:

1
2
export default function shuffle(array) {
...

我们还将把游戏逻辑封装在一个Game类中。这有助于维护数据的完整性,并使代码更安全和可维护。花一分钟阅读代码注释。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
loadCountries('js/data.json')
  .then((data) => {
    const countries = data.countries;
    const game = new Game(countries);
    game.start();
  });

class Game {
  constructor(countries) {
    // 因为我们会对数组进行shift和slice操作,所以需要保持一个完整的副本
    this.masterCountries = countries;
    // 更容易访问元素
    this.DOM = {
      flag: document.querySelector('h2.flag'),
      answerButtons: document.querySelectorAll('.suggestions button')
    }

    // 点击时触发checkAnswer方法
    this.DOM.answerButtons.forEach((button) => {
      button.onclick = (e) => {
        this.checkAnswer(e.target);
      }
    })
  }

  start() {
    // 注意:使用扩展运算符允许我们制作数组的副本而不是引用
    // 这意味着对countries的更改会影响masterCountries
    // 参见4.3 https://github.com/airbnb/javascript#arrays
    this.countries = shuffle([...this.masterCountries]);
    // 获取我们的答案并从数组中移除,这样它就不会重复
    const answer = this.countries.shift();
    // 再选择4个国家,合并我们的答案并洗牌
    const selected = shuffle([answer, ...this.countries.slice(0, 4)]);

    // 更新DOM,从国旗开始
    this.DOM.flag.innerText = answer.flag;
    // 用国家名称更新每个按钮
    selected.forEach((country, index) => {
      const button = this.DOM.answerButtons[index];
      // 移除上一轮的任何类
      button.classList.remove('correct', 'wrong');
      button.innerText = country.name;
      button.dataset.correct = country.name === answer.name;
    });
  }

  checkAnswer(button) {
    const correct = button.dataset.correct === 'true';

    if (correct) {
      button.classList.add('correct');
      alert('正确!做得好!');
      this.start();
    } else {
      button.classList.add('wrong');
      alert('答案错误,请重试');
    }
  }
}

第四步:计分和游戏结束屏幕

让我们更新Game构造函数以处理多轮:

1
2
3
4
5
class Game {
  constructor(countries, numTurns = 3) {
    // 游戏中的回合数
    this.numTurns = numTurns;
    ...

我们的DOM需要更新,以便我们可以处理游戏结束状态,添加重播按钮并显示分数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<main>
  <div class="score">0</div>

  <section class="play">
  ...
  </section>

  <section class="gameover hide">
   <h2>游戏结束</h2>
    <p>你的得分:
      <span class="result">
      </span>
    </p>
    <button class="replay">再玩一次</button>
  </section>
</main>

我们只是隐藏游戏结束部分,直到需要时才显示。

现在,在我们的游戏构造函数中添加对这些新DOM元素的引用:

1
2
3
4
5
6
7
8
9
this.DOM = {
  score: document.querySelector('.score'),
  play: document.querySelector('.play'),
  gameover: document.querySelector('.gameover'),
  result: document.querySelector('.result'),
  flag: document.querySelector('h2.flag'),
  answerButtons: document.querySelectorAll('.suggestions button'),
  replayButtons: document.querySelectorAll('button.replay'),
}

我们还将整理Game的start方法,将显示国家的逻辑移动到一个单独的方法中。这将有助于保持代码的整洁和可管理性。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
start() {
  this.countries = shuffle([...this.masterCountries]);
  this.score = 0;
  this.turn = 0;
  this.updateScore();
  this.showCountries();
}

showCountries() {
  // 获取我们的答案
  const answer = this.countries.shift();
  // 再选择4个国家,合并我们的答案并洗牌
  const selected = shuffle([answer, ...this.countries.slice(0, 4)]);

  // 更新DOM,从国旗开始
  this.DOM.flag.innerText = answer.flag;
  // 用国家名称更新每个按钮
  selected.forEach((country, index) => {
    const button = this.DOM.answerButtons[index];
    // 移除上一轮的任何类
    button.classList.remove('correct', 'wrong');
    button.innerText = country.name;
    button.dataset.correct = country.name === answer.name;
  });
}

nextTurn() {
  const wrongAnswers = document.querySelectorAll('button.wrong')
        .length;
  this.turn += 1;
  if (wrongAnswers === 0) {
    this.score += 1;
    this.updateScore();
  }

  if (this.turn === this.numTurns) {
    this.gameOver();
  } else {
    this.showCountries();
  }
}

updateScore() {
  this.DOM.score.innerText = this.score;
}

gameOver() {
  this.DOM.play.classList.add('hide');
  this.DOM.gameover.classList.remove('hide');
  this.DOM.result.innerText = `${this.score} out of ${this.numTurns}`;
}

在Game构造函数方法的底部,我们将监听重播按钮的点击。在点击事件中,我们通过调用start方法重新开始。

1
2
3
4
5
this.DOM.replayButtons.forEach((button) => {
  button.onclick = (e) => {
    this.start();
  }
});

最后,让我们为按钮添加一些样式,定位分数,并根据需要添加.hide类来切换游戏结束状态。

1
2
3
4
5
button.correct { background: darkgreen; color: #fff; }
button.wrong { background: darkred; color: #fff; }

.score { position: absolute; top: 1rem; left: 50%; font-size: 2rem; }
.hide { display: none; }

进步!我们现在有一个非常简单的游戏。不过它有点平淡。让我们在下一步中解决这个问题。

第五步:添加亮点!

CSS动画是一种非常简单和简洁的方式,可以将静态元素和界面变得生动。

关键帧允许我们定义具有变化CSS属性的动画序列的关键帧。考虑这个用于将国家列表滑入和滑出屏幕的动画:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
.slide-off { animation: 0.75s slide-off ease-out forwards; animation-delay: 1s;}
.slide-on { animation: 0.75s slide-on ease-in; }

@keyframes slide-off {
  from { opacity: 1; transform: translateX(0); }
  to { opacity: 0; transform: translateX(50vw); }
}
@keyframes slide-on {
  from { opacity: 0; transform: translateX(-50vw); }
  to { opacity: 1; transform: translateX(0); }
}

我们可以在开始游戏时应用滑动效果…

1
2
3
4
5
6
7
start() {
  // 重置DOM元素
  this.DOM.gameover.classList.add('hide');
  this.DOM.play.classList.remove('hide');
  this.DOM.play.classList.add('slide-on');
  ...
}

…并在nextTurn方法中

1
2
3
4
5
6
7
8
9
nextTurn() {
  ...
  if (this.turn === this.numTurns) {
    this.gameOver();
  } else {
    this.DOM.play.classList.remove('slide-on');
    this.DOM.play.classList.add('slide-off');
  }
}

我们还需要在检查答案后调用nextTurn方法。更新checkAnswer方法来实现这一点:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
checkAnswer(button) {
  const correct = button.dataset.correct === 'true';

  if (correct) {
    button.classList.add('correct');
    this.nextTurn();
  } else {
    button.classList.add('wrong');
  }
}

一旦slide-off动画完成,我们需要将其滑回并更新国家列表。我们可以基于动画长度设置超时,然后执行此逻辑。幸运的是,使用animationend事件有一个更简单的方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 监听动画结束事件
// 对于.slide-on的情况,我们更改卡片,然后将其移回屏幕
this.DOM.play.addEventListener('animationend', (e) => {
  const targetClass = e.target.classList;
  if (targetClass.contains('slide-off')) {
    this.showCountries();
    targetClass.remove('slide-off', 'no-delay');
    targetClass.add('slide-on');
  }
});

第六步:最终修饰

添加一个标题屏幕不是很好吗?这样用户会得到一些上下文,而不是直接进入游戏。

我们的标记将如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<!-- 在game.start()调用前隐藏分数 -->
<div class="score hide">0</div>

<section class="intro fade-in">
 <h1>
    猜国旗
</h1>
 <p class="guess">🌍</p>
<p>你能认出多少个?</p>
<button class="replay">开始</button>
</section>

<!-- 在game.start()调用前隐藏游戏 -->
<section class="play hide">
...

让我们将介绍屏幕连接到游戏中。我们需要在DOM元素中添加对它的引用:

1
2
3
4
// 在Game构造函数中
this.DOM = {
  intro: document.querySelector('.intro'),
  ....

然后在开始游戏时简单地隐藏它:

1
2
3
4
5
6
start() {
  // 隐藏介绍
  this.DOM.intro.classList.add('hide');
  // 显示分数
  this.DOM.score.classList.remove('hide');
  ...

另外,不要忘记添加新的样式:

1
2
3
4
5
6
7
section.intro p { margin-bottom: 2rem; }
section.intro p.guess { font-size: 8rem; }
.fade-in { opacity: 0; animation: 1s fade-in ease-out forwards; }
@keyframes fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

现在,根据玩家的分数提供评级不是很好吗?这超级容易实现。如更新后的gameOver方法所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const ratings = ['💩','🤣','😴','🤪','👎','😓','😅','😃','🤓','🔥','⭐'];
const percentage = (this.score / this.numTurns) * 100;
// 基于分数计算评级
const rating = Math.ceil(percentage / ratings.length);

this.DOM.play.classList.add('hide');
this.DOM.gameover.classList.remove('hide');
// 重用介绍中的fade-in类
this.DOM.gameover.classList.add('fade-in');
this.DOM.result.innerHTML = `
  ${this.score} out of ${this.numTurns}
  
  你的评级:${this.ratings[rating]}
  `;
}

最后一个修饰:当玩家猜对时有一个漂亮的动画。我们可以再次使用CSS动画来实现这个效果。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
button::before { content: ' '; background: url(../i/star.svg); height: 32px; width: 32px; position: absolute; bottom: -2rem; left: -1rem; opacity: 0; }
button::after {  content: ' '; background: url(../i/star.svg); height: 32px; width: 32px; position: absolute; bottom: -2rem; right: -2rem; opacity: 0; }
/* 为了使上述工作,按钮必须相对定位 */
button { position: relative; }

button.correct::before { animation: sparkle .5s ease-out forwards; }
button.correct::after { animation: sparkle2 .75s ease-out forwards; }

@keyframes sparkle {
  from { opacity: 0; bottom: -2rem; scale: 0.5 }
  to { opacity: 0.5; bottom: 1rem; scale: 0.8; left: -2rem; transform: rotate(90deg); }
}

@keyframes sparkle2 {
  from { opacity: 0; bottom: -2rem; scale: 0.2}
  to { opacity: 0.7; bottom: -1rem; scale: 1; right: -3rem; transform: rotate(-45deg); }
}

我们使用::before和::after伪元素附加背景图像(star.svg),但通过将不透明度设置为0来隐藏它。当按钮具有correct类名时,通过调用sparkle动画来激活它。记住,当选择正确答案时,我们已经将这个类应用到按钮上。

总结和一些额外想法

在不到200行(有大量注释)的javascript中,我们有了一个完全可用的、移动友好的游戏。而且没有任何依赖或库!

当然,我们可以为游戏添加无限的功能和改进。如果你喜欢挑战,这里有一些想法:

  • 为正确和错误的答案添加基本音效
  • 使用web workers使游戏可离线使用
  • 在localstorage中存储统计数据,如游戏次数、总体评级,并显示
  • 添加一种在社交媒体上分享分数和挑战朋友的方式
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计