文章目录
项目结构
代码
Snake.ts
class Snake {
snake: HTMLElement;
head: HTMLElement;
bodies: HTMLCollectionOf<HTMLElement>; //数组内 Element 类型转换成 HTMLElement
stageWidth: number;
stageHeight: number;
lengthMT1: boolean = false; // length more than 1
constructor(stageWidth: number, stageHeight: number) {
this.snake = document.getElementById('snake')!;
// 断言可以代替 ! 功能
this.head = document.querySelector('#snake > div') as HTMLElement;
// querySelectorAll 获取的是 NodeListOf<Element> 静态集合 故不用
// this.bodies = document.querySelectorAll('#snake > div');
// HTMLCollection 动态集合
this.bodies = this.snake.getElementsByTagName('div');
this.stageHeight = stageHeight;
this.stageWidth = stageWidth;
}
get X() {
return this.head.offsetLeft;
}
get Y() {
return this.head.offsetTop;
}
set X(value: number) { //不能设置 offsetLeft, 只读
if (this.X === value) return;
if (value < 0 || value > (this.stageWidth - 1) * 10) {
throw new Error('🐍撞墙了!')
}
this.moveBody()
this.head.style.left = value + 'px';
this.checkCrash()
}
set Y(value: number) {
if (this.Y === value) return;
if (value < 0 || value > (this.stageHeight - 1) * 10) {
throw new Error('🐍撞墙了!')
}
this.moveBody()
this.head.style.top = value + 'px';
this.checkCrash()
}
growUp() {
this.lengthMT1 = true;
this.snake.insertAdjacentHTML('beforeend', '<div></div>');
}
moveBody() {
// 此处使用 后一节 身体替代 前一节 身体位置,而不是将蛇尾放到蛇头要更新的位置,
// 是因为改变内联样式并不会改变 bodies 的顺序,这样通过 bodies 拿到的蛇尾和蛇头dom 并不是视觉上的蛇尾蛇头,
// 而仍然是文档流中的 第一个子元素 和 最后一个子元素,并不会更新为新的蛇尾蛇头,最终只是不断内耗,两者不断相互替换,整个蛇停滞不前
for (let i = this.bodies.length - 1; i > 0; i--) {
let X = this.bodies[i - 1].offsetLeft;
let Y = this.bodies[i - 1].offsetTop;
this.bodies[i].style.left = X + 'px';
this.bodies[i].style.top = Y + 'px';
}
}
checkCrash(){
for(let i=1;i<this.bodies.length;i++){
let b = this.bodies[i];
if(this.X===b.offsetLeft&&this.Y===b.offsetTop) {
throw new Error('撞到自己了!');
}
}
}
}
export default Snake;
Food.ts
// 定义食物类
class Food {
// 定义一个属性表示食物所对应的元素
element: HTMLElement;
stageWidth: number;
stageHeight: number;
constructor(stageWidth: number, stageHeight: number) {
// ! 即不存在 null 的情况 or null 也可以赋值
this.element = document.getElementById('food')!;
this.stageWidth = stageWidth;
this.stageHeight = stageHeight;
}
// 获取食物坐标轴
get X() {
return this.element.offsetLeft;
}
get Y() {
return this.element.offsetTop;
}
change() {
// 长300,宽300 则,X (0,290) Y(0,290) X,Y为10倍数 🐍移动一次就是一格,10px
// Math.round() 四舍五入
let top = Math.round(Math.random() * (this.stageWidth-1)) * 10;
let left = Math.round(Math.random() * (this.stageHeight-1)) * 10;
this.element.style.left = left + 'px';
this.element.style.top = top + 'px';
}
}
export default Food;
ScorePanel.ts
// 记分牌
class ScorePanel {
score = 0;
level = 1;
scoreEl: HTMLElement;
levelEl: HTMLElement;
maxLevel: number;
upScore: number; //多少分升一级
constructor(maxLevel:number= 10, upScore:number= 10) {
this.scoreEl = document.getElementById('score')!;
this.levelEl = document.getElementById('level')!;
this.maxLevel = maxLevel;
this.upScore = upScore;
}
addScore() {
this.scoreEl.innerHTML = ++this.score + '';
if (this.score % this.upScore === 0) this.levelUp();
}
levelUp() {
if (this.level < this.maxLevel)
this.levelEl.innerHTML = ++this.level + '';
}
}
export default ScorePanel;
GameControl.ts
import Food from "./Food";
import Snake from "./Snake";
import ScorePanel from "./ScorePanel";
// 游戏控制器 控制其他所有类
class GameControl {
// 定义三个属性
snake: Snake;
food: Food;
scorePanel: ScorePanel;
stageWidth: number;
stageHeight: number;
isAlive: boolean = true;
// 蛇的移动方向
direction: string = '';
directionOptions = [
'ArrowUp', //chrome
'Up', //IE
'ArrowDown',
'Down',
'ArrowLeft',
'Left',
'ArrowRight',
'Right',
]
constructor(stageWidth: number = 30, stageHeight: number = 30) {
this.scorePanel = new ScorePanel()
this.snake = new Snake(stageWidth, stageHeight)
this.food = new Food(stageWidth, stageHeight);
this.stageHeight = stageHeight;
this.stageWidth = stageWidth;
this.food.change()
this.init()
}
init() {
// 绑定键盘按下事件
// 严格模式下,this默认指向调用函数的对象实例 下行 handleKeydown 函数内 this 为 document
// document.addEventListener('keydown', this.handleKeydown)
// ES5 引入了 Function.prototype.bind。 handleKeydown 的 this 将永久地被绑定到了 bind 的第一个参数
document.addEventListener('keydown', this.handleKeydown.bind(this))
this.run()
}
handleKeydown(e: KeyboardEvent) {
if (this.directionOptions.indexOf(e.key) > -1) {
if(this.snake.lengthMT1){//阻止掉头
if ((this.direction === 'ArrowUp' || this.direction === 'Up') && (e.key === 'ArrowDown' || e.key === 'Down')) {
return;
}
if ((this.direction === 'ArrowDown' || this.direction === 'Down') && (e.key === 'ArrowUp' || e.key === 'Up')) {
return;
}
if ((this.direction === 'ArrowLeft' || this.direction === 'Left') && (e.key === 'ArrowRight' || e.key === 'ArrowRight')) {
return;
}
if ((this.direction === 'ArrowRight' || this.direction === 'ArrowRight') && (e.key === 'ArrowLeft' || e.key === 'Left')) {
return;
}
}
this.direction = e.key;
}
}
run() {
let X = this.snake.X;
let Y = this.snake.Y;
switch (this.direction) {
case 'ArrowUp':
case 'Up':
Y -= 10;
break;
case 'ArrowDown':
case 'Down':
Y += 10;
break;
case 'ArrowLeft':
case 'Left':
X -= 10;
break;
case 'ArrowRight':
case 'Right':
X += 10;
break;
}
this.checkEat(X, Y);
try {
this.snake.X = X;
this.snake.Y = Y;
} catch (e: any) {
alert(e.message + 'GAME OVER!');
this.isAlive = false;
}
// 开启一个定时调用
this.isAlive && setTimeout(this.run.bind(this), 200 - (this.scorePanel.level - 1) * 30)
}
checkEat(X: number, Y: number) {
if (X === this.food.X && Y === this.food.Y) {
this.food.change();
this.scorePanel.addScore();
this.snake.growUp();
}
}
}
export default GameControl;
index.less
@bg-color: #b7d4a8; //分号一定要加
// 清除默认样式
* {
margin: 0;
padding: 0;
// 改变盒模型计算方式
box-sizing: border-box;
}
body {
font: bold 20px "Courier";
}
// 设置主窗口样式
#main {
width: 360px;
height: 420px;
background-color: @bg-color;
margin: 100px auto;
border: 10px solid #000;
border-radius: 40px;
display: flex;
flex-flow: column;
justify-content: space-around;
align-items: center;
#stage {
width: 304px;
height: 304px;
border: 2px solid #000;
position: relative;
#snake {
& > div {
width: 10px;
height: 10px;
background-color: #000;
border: 1px solid @bg-color;
position: absolute;
color: #fff;
}
}
& > #food {
height: 10px;
width: 10px;
position: absolute;
display: flex;
flex-flow: row wrap;
justify-content: space-between;
align-content: space-between;
& > div {
width: 4px;
height: 4px;
background-color: #000;
transform: rotate(45deg);
}
}
}
#score-panel {
width: 300px;
display: flex;
justify-content: space-between;
}
}
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>贪吃蛇</title>
</head>
<body>
<div id="main">
<div id="stage">
<div id="snake">
<div></div>
</div>
<div id="food">
<div></div>
<div></div>
<div></div>
<div></div>
</div>
</div>
<div id="score-panel">
<div>
SCORE:<span id="score">0</span>
</div>
<div>
LEVEL:<span id="level">1</span>
</div>
</div>
</div>
</body>
</html>
index.ts
import './style/index.less'
import GameControl from "./modules/GameControl";
const gameControl = new GameControl()
package.json
{
"name": "SnakeGame",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "webpack",
"start": "webpack serve --open --mode development"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.16.0",
"@babel/preset-env": "^7.16.4",
"babel-loader": "^8.2.3",
"clean-webpack-plugin": "^4.0.0",
"core-js": "^3.19.1",
"css-loader": "^6.5.1",
"html-webpack-plugin": "^5.5.0",
"less": "^4.1.2",
"less-loader": "^10.2.0",
"postcss": "^8.3.11",
"postcss-loader": "^6.2.0",
"postcss-preset-env": "^7.0.0",
"style-loader": "^3.3.1",
"ts-loader": "^9.2.6",
"typescript": "^4.5.2",
"webpack": "^5.64.1",
"webpack-cli": "^4.9.1",
"webpack-dev-server": "^4.5.0"
}
}
tsconfig.json
{
"compilerOptions": {
"module": "es2015",
"target": "es2015",
"strict": true,
"noEmitOnError": true,
"removeComments": true,
},
"exclude": [
"node_modules"
]
}
webpack.config.js
const path = require('path');
// 引入html插件
const HTMLWebpackPlugin = require('html-webpack-plugin')
//引入clean插件
const {CleanWebpackPlugin} = require('clean-webpack-plugin')
// webpack中配置信息都应该写在module.exports中
module.exports = {
// 入口文件
entry: './src/index.ts',
// 指定打包文件所在目录
output: {
path: path.resolve(__dirname, 'dist'),
//打包后的文件
filename: "bundle.js",
//告诉webpack不使用箭头函数
environment:{
arrowFunction:false,
const: false
}
},
//webpack打包时要使用的模块
module: {
// 指定要加载的规则
rules: [
{
//test指定规则生效的文件
test: /\.ts$/,
//要使用的loader 从后往前 先用ts-loader将ts转换成js 再将js转换成旧版本js
use: [
//配置babel
{
//指定加载器
loader: 'babel-loader',
//设置babel
options: {
//设置预定义的环境
presets: [
[
//指定环境插件
"@babel/preset-env",
//配置信息
{
targets: {//要兼容的目标浏览器
"chrome": '58',
'ie': "11"
},
"corejs": '3', //版本3.x.x
"useBuiltIns": "usage" //使用core-js的方式 按需加载
}
]
]
}
},
'ts-loader'
],
//要排除的文件
exclude: /node_modules/
},
{
test:/\.less$/,
use:[
"style-loader",
"css-loader",
//引入postcss
{
loader: "postcss-loader",
options: {
postcssOptions:{
plugins: [
[
'postcss-preset-env', //预制环境
{
browsers:'last 2 versions' //兼容每种浏览器的两个最新版本
}
]
]
}
}
},
"less-loader",
]
}
]
},
// 配置Webpack 插件
plugins: [
new CleanWebpackPlugin(), //删除dist
new HTMLWebpackPlugin({
template: "./src/index.html"
}),
],
// 用来设置引用模块
resolve: {
extensions: ['.ts', '.js','.less']
},
mode:'production'
}
实现效果
本来背景是绿色的,不知为何截图就变白了。
实际课程参考尚硅谷的超哥TS课程,但最终实现有出入。
课程地址