使用原生JS和CSS构建浏览器游戏
构思
我们将构建一个国旗猜谜游戏。玩家会看到一个国旗图案和一个多项选择答案列表。
第一步:基础结构
首先需要国家列表及其对应国旗。幸运的是,我们可以使用表情符号来显示国旗,这意味着我们不需要自己寻找或创建它们。我已将其准备为JSON格式。
最简单的界面将显示一个国旗表情符号和五个按钮:
1
|
/* 使用网格居中所有内容,相对尺寸确保从最小屏幕到最大显示器都能良好显示 */
|
项目文件结构如下:
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
|
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
|
function startGame(countries) {
// 随机化国家列表
shuffle(countries);
let answer = countries.shift();
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('答案错误,请重试');
}
}
|
Fisher-Yates洗牌算法实现:
1
2
3
4
5
6
7
8
9
10
11
12
|
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;
}
|
第三步:类结构
使用模块化保持代码结构:
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";
|
每个函数需要添加export default:
1
2
3
|
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
|
loadCountries('js/data.json')
.then((data) => {
const countries = data.countries;
const game = new Game(countries);
game.start();
});
class Game {
constructor(countries) {
this.masterCountries = countries;
this.DOM = {
flag: document.querySelector('h2.flag'),
answerButtons: document.querySelectorAll('.suggestions button')
}
this.DOM.answerButtons.forEach((button) => {
button.onclick = (e) => {
this.checkAnswer(e.target);
}
})
}
start() {
this.countries = shuffle([...this.masterCountries]);
const answer = this.countries.shift();
const selected = shuffle([answer, ...this.countries.slice(0, 4)]);
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
6
|
class Game {
constructor(countries, numTurns = 3) {
this.numTurns = numTurns;
// ...
}
}
|
更新DOM结构:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
<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
|
this.DOM = {
score: document.querySelector('.score'),
play: document.querySelector('.play'),
gameover: document.querySelector('.gameover'),
result: document.querySelector('.result'),
// ...
}
|
重构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
|
start() {
this.countries = shuffle([...this.masterCountries]);
this.score = 0;
this.turn = 0;
this.updateScore();
this.showCountries();
}
showCountries() {
const answer = this.countries.shift();
const selected = shuffle([answer, ...this.countries.slice(0, 4)]);
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} / ${this.numTurns}`;
}
|
添加重播按钮监听:
1
2
3
4
5
|
this.DOM.replayButtons.forEach((button) => {
button.onclick = (e) => {
this.start();
}
});
|
添加样式:
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关键帧动画:
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); }
}
|
在start方法中应用滑动效果:
1
2
3
4
5
6
|
start() {
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');
}
}
|
更新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');
}
}
|
监听animationend事件:
1
2
3
4
5
6
7
8
|
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
|
<div class="score hide">0</div>
<section class="intro fade-in">
<h1>猜国旗</h1>
<p class="guess">🌍</p>
<p>你能认出多少?</p>
<button class="replay">开始</button>
</section>
<section class="play hide">
<!-- 游戏内容 -->
</section>
|
在Game构造函数中添加引用:
1
2
3
4
|
this.DOM = {
intro: document.querySelector('.intro'),
// ...
}
|
开始游戏时隐藏介绍:
1
2
3
4
5
|
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; }
}
|
根据得分提供评级:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
gameOver() {
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');
this.DOM.gameover.classList.add('fade-in');
this.DOM.result.innerHTML = `
${this.score} / ${this.numTurns}
您的评级: ${ratings[rating]}
`;
}
|
正确答案动画效果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
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); }
}
|
总结与额外想法
在不到200行(有详细注释)的JavaScript代码中,我们有了一个完全可用的移动友好游戏。而且没有任何依赖或库!
当然,我们可以为游戏添加无限的功能和改进。如果你喜欢挑战,这里有一些想法:
- 为正确和错误答案添加基本音效
- 使用Web Workers使游戏可离线使用
- 在localStorage中存储游戏次数、总体评分等统计数据并显示
- 添加分享得分和在社交媒体上挑战朋友的方式