TypeScript --- 练习案例 --- 贪吃蛇

0. 开发环境搭建

package.json

{
  "name": "typescript_demo",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "webpack",
    "start": "webpack server --open chrome.exe"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@babel/core": "^7.25.2",
    "@babel/preset-env": "^7.25.3",
    "babel-loader": "^9.1.3",
    "clean-webpack-plugin": "^4.0.0",
    "core-js": "^3.38.0",
    "css-loader": "^7.1.2",
    "html-webpack-plugin": "^5.6.0",
    "less": "^4.2.0",
    "less-loader": "^12.2.0",
    "postcss": "^8.4.41",
    "postcss-loader": "^8.1.1",
    "postcss-preset-env": "^10.0.0",
    "style-loader": "^4.0.0",
    "ts-loader": "^9.5.1",
    "typescript": "^5.5.4",
    "webpack": "^5.93.0",
    "webpack-cli": "^5.1.4",
    "webpack-dev-server": "^5.0.4"
  }
}

tsconfig.json

{
  "compilerOptions": {
    "module": "ES2015",
    "target": "ES2015",
    "strict": true,
    "noEmitOnError": true
  }
}

webpack.config.js

const path = require('path');
const HTMLWebpackPlugin = require('html-webpack-plugin');
const {CleanWebpackPlugin} = require('clean-webpack-plugin')

module.exports = {
    // 指定入口文件
    entry: "./src/main.ts",
    mode: "production",
    // 打包配置
    output: {
        // 指定打包后的文件所在目录
        path: path.resolve(__dirname, "./dist"),

        // 打包后的文件名
        filename: "bundle.js",

        // 配置打包环境
        environment: {
            arrowFunction: false,
            const: false
        }
    },

    // 指定 webpack 打包时要使用的模块
    module: {
        // 指定加载的规则
        rules: [
            {
                // test 指定的是规则生效的文件, 正则表达式
                test: /\.ts$/,
                // 要使用的 loader
                use: [
                    {
                        loader: "babel-loader",
                        options: {
                            presets: [
                                [
                                    "@babel/preset-env",
                                    {
                                        targets: {
                                            "chrome": "88",
                                            "ie": "11"
                                        },
                                        "corejs": "3",
                                        "useBuiltIns": "usage"
                                    }]
                            ]
                        }
                    },
                    "ts-loader"
                ],
                // 要排除的文件
                exclude: /node_modules/,
            },
            {
                test: /\.less$/,
                use: [
                    "style-loader",
                    "css-loader",
                    {
                        loader: "postcss-loader",
                        options: {
                            postcssOptions: {
                                plugins: [
                                    [
                                        "postcss-preset-env",
                                        {
                                            browsers: ["last 2 versions"],
                                        }
                                    ]
                                ]
                            }
                        }
                    },
                    "less-loader"
                ]
            }
        ]
    },

    // 配置 webpack 插件
    plugins: [
        new HTMLWebpackPlugin({
            // 设置模版路径
            template: path.resolve(__dirname, "./src/index.html"),
            title: "贪吃蛇"
        }),   // webpack 管理 html 页面的插件
        new CleanWebpackPlugin(),  // 每次打包前先清除 dist 文件夹下的所有打包文件
    ],

    // 用来配置引用模块, 哪些模块可以作为模块使用
    resolve: {
        extensions: [".ts", ".js"]
    }
}

执行命令

npm i

npm run start

1. 设计图

2. 页面构建

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div class="container">
    <!-- 游戏的舞台 -->
    <div class="stage">
        <!-- 蛇的容器 -->
        <div id="snake">
            <!-- 蛇的各个部分 -->
            <div></div>
        </div>

        <!-- 食物 -->
        <div id="food">
            <!-- 添加四个小 div 来设置食物的样式 -->
            <div></div>
            <div></div>
            <div></div>
            <div></div>
        </div>
    </div>

    <!-- 游戏的记分牌 -->
    <div class="score-panel">
        <!-- 左侧记分牌 -->
        <div>
            SCORE: <span id="score">0</span>
        </div>

        <!-- 右侧难度等级 -->
        <div>
            LEVEL: <span id="level">1</span>
        </div>
    </div>
</div>
</body>
</html>

index.less

// 设置变量
@bg-color: #b7d4a8;

// 清除默认样式
* {
  margin: 0;
  padding: 0;
  // 改变盒子模型的计算方式, 以 border 为基础开始计算
  box-sizing: border-box;
}

body {
  font: bold 20px "Courier";
}


.container {
  width: 360px;
  height: 420px;
  background-color: @bg-color;
  margin: 100px auto;
  border: 10px solid black;
  border-radius: 30px;

  display: flex;
  flex-direction: column;
  justify-content: space-around;
  align-items: center;

  // 游戏舞台样式
  .stage {
    width: 304px;
    height: 304px;
    border: 2px solid black;
    position: relative;

    // 蛇容器
    #snake {
      & > div {
        width: 10px;
        height: 10px;
        background-color: #000;
        border: 1px solid @bg-color;
        // 开启绝对定位
        position: absolute;
      }
    }

    #food {
      width: 10px;
      height: 10px;
      border: 1px solid @bg-color;
      // 开启绝对定位
      position: absolute;

      display: flex;
      flex-flow: row wrap; // 设置主轴换行
      // 设置主轴和侧轴的空白空间分配到元素中间
      justify-content: space-between;
      align-content: space-between;

      & > div {
        width: 4px;
        height: 4px;
        background-color: black;
        transform: rotate(45deg); // 旋转 45 度
      }
    }
  }

  // 记分牌
  .score-panel {
    width: 300px;
    height: 50px;
    display: flex;
    justify-content: space-between;
  }
}

3. TS 交互

  1. 食物的 X Y 轴坐标和蛇的X Y 轴坐标重合时, 证明蛇迟到了这个食物
  2. 为了保证食物的位置出现在游戏盘中, 食物的坐标位置, 最小为0, 最大是290,蛇每次移动为 10px ,所以要设置食物位置必须为 10 的整倍数

Food.ts

// 定义食物类
class Food {
    // 定义一个属性来表示食物所对应的元素
    element: HTMLElement
    widthDivideTen: number   // 舞台的最大宽度 / 10

    constructor(widthDivideTen: number = 29) {
        this.widthDivideTen = widthDivideTen
        // 获取页面中的 food 元素
        this.element = document.getElementById("food")!;
    }

    // 获取食物 X轴 坐标的方法
    get X() {
        return this.element.offsetLeft
    }

    // 获取食物 Y轴 坐标的方法
    get Y() {
        return this.element.offsetTop
    }

    // 随机生成食物出现位置的方法
    change() {
        // 食物的坐标位置, 最小为0, 最大是290,
        // 蛇每次移动为 10px ,所以要设置食物位置必须为 10 的整倍数
        let top = Math.round(Math.random() * this.widthDivideTen) * 10
        let left = Math.round(Math.random() * this.widthDivideTen) * 10

        this.element.style.left = left + 'px'
        this.element.style.top = top + 'px'
    }
}

export default Food

ScorePanel.ts

// 定义记分牌的类
class ScorePanel {
    private score: number = 0
    private level: number = 1
    maxLevel: number    // 最高等级
    upNum: number   // 多少分的倍数可以升级
    private scoreSpan = HTMLElement
    private levelSpan = HTMLElement

    constructor(maxLevel: number = 10, upNum: number = 10) {
        this.maxLevel = maxLevel
        this.upNum = upNum
        this.scoreSpan = document.getElementById("score")!;
        this.levelSpan = document.getElementById("level")!;
    }

    // 加分
    addScore() {
        this.scoreSpan.innerHTML = ++this.score + ''

        // 判断分数是否符合升级条件
        if (this.score % this.upNum === 0) {
            this.levelUp()
        }
    }

    get gameLevel(){
        return this.level
    }
    // 提升等级, 最大等级为 10 级
    levelUp() {
        if (this.level < this.maxLevel) {
            this.levelSpan.innerHTML = ++this.level + ''
        }
    }

}

export default ScorePanel

Snake.ts

// 定义蛇的类
class Snake {
    // 蛇容器
    element: HTMLElement;

    // 蛇头
    head: HTMLElement
    // 蛇的身体(包括舌头)
    bodies: HTMLCollection


    constructor() {
        this.element = document.getElementById("snake")!;
        this.head = document.querySelector('#snake > div') as HTMLElement
        this.bodies = this.element.getElementsByTagName("div")
    }

    // 获取蛇头的 X轴 坐标
    get X() {
        return this.head.offsetLeft
    }

    // 获取蛇头的 Y轴 坐标
    get Y() {
        return this.head.offsetTop
    }

    // 设置蛇头的 X轴 坐标
    set X(value: number) {
        // 如果新值和旧值相同, 则证明蛇是在纵向移动, 不用修改 X 的值
        if (this.X === value) return
        // 判断蛇移动的横坐标是否在合法范围内( 撞墙, 退出游戏 )
        if (value < 0 || value > 290) {
            throw new Error("蛇撞墙了! 游戏结束!")
        }

        // 修改 X 时, 就是在修改 X 轴坐标, 蛇在水平方向运动时, 不允许其向水平的相反方向运动
        // 检查 蛇头 和 第二节身体的坐标是否一致
        if (this.bodies[1] && (this.bodies[1] as HTMLElement).offsetLeft === value) {
            // 如果发生了掉头, 需要向按下键盘的相反的方向继续移动
            if (value > this.X) {
                // 如果新值大于旧值 X, 则说明蛇正在向右运动, 此时如果发生掉头, 应该继续向右移动, 需要将 value 的值 - 10
                value = this.X - 10
            } else {
                value = this.X + 10
            }
        }

        // 移动身体
        this.moveBody()
        this.head.style.left = value + 'px'

        // 检查蛇头的坐标是否和身体的坐标重合
        this.checkHeadBody()
    }

    // 设置蛇头的 Y轴 坐标
    set Y(value: number) {
        // 如果新值和旧值相同, 则证明蛇是在恒向移动, 不用修改 Y 的值
        if (this.Y === value) return
        if (value < 0 || value > 290) {
            throw new Error("蛇撞墙了! 游戏结束!")
        }
        // 修改 Y 时, 就是在修改 Y 轴坐标, 蛇在垂直方向运动时, 不允许其向垂直的相反方向运动
        // 检查 蛇头 和 第二节身体的坐标是否一致
        if (this.bodies[1] && (this.bodies[1] as HTMLElement).offsetTop === value) {
            // 如果发生了掉头, 需要向按下键盘的相反的方向继续移动
            if (value > this.Y) {
                // 如果新值大于旧值 Y, 则说明蛇正在向下运动, 此时如果发生掉头, 应该继续向下移动, 需要将 value 的值 - 10
                value = this.Y - 10
            } else {
                value = this.Y + 10
            }
        }

        // 移动身体
        this.moveBody()
        this.head.style.top = value + 'px'

        // 检查蛇头的坐标是否和身体的坐标重合
        this.checkHeadBody()
    }

    // 设置蛇身体增加一节
    addBody() {
        this.element.insertAdjacentHTML('beforeend', `<div></div>`)
    }

    // 移动 蛇 的身体, 后面的每一节身体,要移动到前一节的位置
    moveBody() {
        // 从后往前改, 第四节的位置 === 第三节的位置
        for (let i = this.bodies.length - 1; i > 0; i--) {
            // 获取前面身体的位置
            let X = (this.bodies[i - 1] as HTMLElement).offsetLeft;
            let Y = (this.bodies[i - 1] as HTMLElement).offsetTop;

            (this.bodies[i] as HTMLElement).style.left = X + 'px';
            (this.bodies[i] as HTMLElement).style.top = Y + 'px';
        }
    }

    // 蛇头是否撞到自己
    checkHeadBody(){
        // 检查蛇头的坐标是否和身体的坐标重合
        for (let i = 1; i < this.bodies.length; i++) {
            if (this.X === (this.bodies[i] as HTMLElement).offsetLeft && this.Y === (this.bodies[i] as HTMLElement).offsetTop) {
                throw new Error("撞到自己了! ")
            }
        }
    }

}

export default Snake;

GameControl.ts

import Snake from "./Snake";
import Food from "./Food";
import ScorePanel from "./ScorePanel";

// 游戏控制器, 控制其他的所有类
class GameControl {
    snake: Snake
    food: Food
    scorePanel: ScorePanel

    // 存储用户按键的方向 ( 即蛇的移动方向 )
    direction: string = ''

    // 游戏是否结束
    isLive = true

    constructor() {
        this.snake = new Snake();
        this.food = new Food();
        this.scorePanel = new ScorePanel();

        this.init()
    }

    // 游戏的初始化方法, 调用后游戏开始
    init() {
        // 绑定键盘的按下事件, 在回调函数使用 bind() 创建一个新函数, 并传递 GameControl 对象
        document.addEventListener('keydown', this.keyDownHandler.bind(this));

        // 调用 run() 使蛇进行移动
        this.run()
    }

    keyDownHandler(e: KeyboardEvent) {
        // 键盘按下事件得到的字符串 Chrome 和 IE 浏览器中的不一样
        /** Chrome      IE
         * ArrowUp     Up
         * ArrowDown   Down
         * ArrowLeft   Left
         * ArrowRight  Right
         * /
         */
        // 检查 用户按下的键 是否合法
        this.direction = e.key;
    }

    // 控制 蛇 移动的方法
    run() {
        // 根据 this.direction 来使蛇的位置发生改变
        /*
        * 向上  top 减少
        * 向下  top 增加
        * 向左  left 减少
        * 向右  left 增加
        * */

        // 获取蛇现在的坐标
        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) {
            // @ts-ignore
            alert(e.message)
            // 将 isLIve 设置为 false
            this.isLive = false

        }


        // 开启定时调用, 并且根据等级 提升移动速度
        this.isLive && setTimeout(this.run.bind(this), 300 - (this.scorePanel.gameLevel - 1) * 30)
    }

    // 检查蛇是否吃到食物
    checkEat(X: number, Y: number) {
        if (X === this.food.X && Y === this.food.Y){
            // 食物位置重置
            this.food.change()

            // 分数增加
            this.scorePanel.addScore()

            // 蛇的身体 +1
            this.snake.addBody()
        }
    }

}

export default GameControl;

main.ts

import './css/index.less'
import GameControl from "./module/GameControl";

const gc = new GameControl();

setInterval(()=>{
    console.log(gc.direction)
},1000)
posted @ 2024-08-07 14:29  河图s  阅读(11)  评论(0)    收藏  举报