一起用Go做一个小游戏(上)

news2024/11/25 11:28:26

引子

最近偶然看到一个Go语言库,口号喊出“一个超级简单(dead simple)的2D游戏引擎”,好奇点开了它的官网。

4f0f78d0cfe44473c2a5879a02e0ad3c.png

官网上已经有很多可以在线体验的小游戏了(利用WASM技术)。例如曾经风靡一时的2048:

563df0573c9f239ff1b3d533fddcaa1c.png

当然只要安装了Go,我们也键入下面的命令本地运行这个游戏:

$ go run -tags=example github.com/hajimehoshi/ebiten/v2/examples/2048@latest

还有童年《俄罗斯方块》:

3f5a29f602b3af49ce991b53e3ebe9f9.png

有14年左右让无数人疯狂的《Flappy Bird》(或许称为Flappy Gopher更贴切一点😀):

534e6150d800a67b037fb3f2b142bbd7.png

这些瞬间让我产生了极大的兴趣。简单浏览一下文档,整体感觉下来,虽然与成熟的游戏引擎(如Cocos2dx,DirectX,Unity3d等)相比,ebiten功能还不算丰富。但是麻雀虽小,五脏俱全。ebiten的API设计比较简单,使用也很方便,即使对于新手也可以在1-2个小时内掌握,并开发出一款简单的游戏。更妙的是,Go语言让ebitengine实现了跨平台!

接下来的3篇文章,我会介绍ebitengine这个库。对于游戏引擎来说,只介绍它的API用法似乎有点纸上谈兵。恰好我想起之前看到一个《外星人入侵》的小游戏,刚好可以拿来练手。那请大家坐稳扶好,我们出发咯。

安装

ebitengine 要求Go版本 >= 1.15。使用go module下载这个包:

$ go get -u github.com/hajimehoshi/ebiten/v2

显示窗口

游戏开发第一步是将游戏窗口显示出来,并且能在窗口上显示一些文字。先看代码:

package main

import (
  "log"

  "github.com/hajimehoshi/ebiten/v2"
  "github.com/hajimehoshi/ebiten/v2/ebitenutil"
)

type Game struct{}

func (g *Game) Update() error {
  return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
  ebitenutil.DebugPrint(screen, "Hello, World")
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
  return 320, 240
}

func main() {
  ebiten.SetWindowSize(640, 480)
  ebiten.SetWindowTitle("外星人入侵")
  if err := ebiten.RunGame(&Game{}); err != nil {
    log.Fatal(err)
  }
}

使用命令go run运行该程序:

$ go run main.go

我们会看到一个窗口,标题为外星人入侵,并且左上角显示了文字Hello,World

5656a6e8c6d7b755a1f3fb6461e25b8a.png

现在我们来分析使用ebiten开发的游戏程序的结构。

首先,ebiten引擎运行时要求传入一个游戏对象,该对象的必须实现ebiten.Game这个接口:

// Game defines necessary functions for a game.
type Game interface {
  Update() error
  Draw(screen *Image)
  Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int)
}

ebiten.Game接口定义了ebiten游戏需要的3个方法:Update,DrawLayout

  • Update:每个tick都会被调用。tick是引擎更新的一个时间单位,默认为1/60s。tick的倒数我们一般称为帧,即游戏的更新频率。默认ebiten游戏是60帧,即每秒更新60次。该方法主要用来更新游戏的逻辑状态,例如子弹位置更新。上面的例子中,游戏对象没有任何状态,故Update方法为空。注意到Update方法的返回值为error类型,当Update方法返回一个非空的error值时,游戏停止。在上面的例子中,我们一直返回nil,故只有关闭窗口时游戏才停止。

  • Draw:每帧(frame)调用。帧是渲染使用的一个时间单位,依赖显示器的刷新率。如果显示器的刷新率为60Hz,Draw将会每秒被调用60次。Draw接受一个类型为*ebiten.Image的screen对象。ebiten引擎每帧会渲染这个screen。在上面的例子中,我们调用ebitenutil.DebugPrint函数在screen上渲染一条调试信息。由于调用Draw方法前,screen会被重置,故DebugPrint每次都需要调用。

  • Layout:该方法接收游戏窗口的尺寸作为参数,返回游戏的逻辑屏幕大小。我们实际上计算坐标是对应这个逻辑屏幕的,Draw将逻辑屏幕渲染到实际窗口上。这个时候可能会出现伸缩。在上面的例子中游戏窗口大小为(640, 480),Layout返回的逻辑大小为(320, 240),所以显示会放大1倍。

在main函数中,

ebiten.SetWindowSize(640, 480)

设置游戏窗口的大小。

ebiten.SetWindowTitle("外星人入侵")

设置窗口标题,标题显示在窗口的左上角。

一切准备就绪,创建一个Game对象,调用ebiten.RunGame()运行。是不是很简单?

处理输入

没有交互的游戏不是真的游戏!下面我们来监听键盘的输入,当前只处理3个键:左方向←,右方向→和空格。

ebiten提供函数IsKeyPressed来判断某个键是否按下,同时内置了100多个键的常量定义,见源码keys.go文件。ebiten.KeyLeft表示左方向键,ebiten.KeyRight表示右方向键,ebiten.KeySpace表示空格。

为了代码清晰,我们定义一个Input结构来处理输入:

type Input struct {
  msg string
}

func (i *Input) Update() {
  if ebiten.IsKeyPressed(ebiten.KeyLeft) {
    fmt.Println("←←←←←←←←←←←←←←←←←←←←←←←")
    i.msg = "left pressed"
  } else if ebiten.IsKeyPressed(ebiten.KeyRight) {
    fmt.Println("→→→→→→→→→→→→→→→→→→→→→→→")
    i.msg = "right pressed"
  } else if ebiten.IsKeyPressed(ebiten.KeySpace) {
    fmt.Println("-----------------------")
    i.msg = "space pressed"
  }
}

Game结构中添加一个Input类型的字段,并且为了方便新增NewGame方法用于创建Game对象:

type Game struct {
  input *Input
}

func NewGame() *Game {
  return &Game{
    input: &Input{msg: "Hello, World!"},
  }
}

Game结构的Update方法中,我们需要调用InputUpdate方法触发按键的判断:

func (g *Game) Update() error {
  g.input.Update()
  return nil
}

Game的Draw方法中将显示Inputmsg字段:

func (g *Game) Draw(screen *ebiten.Image) {
  ebitenutil.DebugPrint(screen, g.input.msg)
}

将main函数中创建Game对象的方式修改如下:

game := NewGame()

if err := ebiten.RunGame(game); err != nil {
  log.Fatal(err)
}

使用go run命令运行:

$ go run main.go

窗口与前一个例子相同,然而我们可以在窗口上按←→和空格,观察控制台输出:

74d3d80d822960e6a8e4cd72d32dca43.png

设置背景

黑色背景看起来有些无趣,我们现在就来换一个背景。

func (g *Game) Draw(screen *ebiten.Image) {
  screen.Fill(color.RGBA{R: 200, G: 200, B: 200, A: 255})
  ebitenutil.DebugPrint(screen, g.input.msg)
}

ebiten.Image定义了一个名为Fill的方法,可以传入一个颜色对象color.RGBA,将背景填充为特定颜色。Draw函数的参数为*ebiten.Image类型,它表示的是屏幕对象,ebitengine引擎最终会将screen显示出来,故填充它的背景即可修改窗口的背景。代码中我们将背景颜色修改为灰色(R:200,G:200,B:200)。

注意:由于每帧都会调用Draw方法刷新屏幕内容,所以每次调用都需要填充背景。

运行结果如下:3ae8b7fb4836330a256a2e7409434c6d.png

第一次重构

目前为止,我们的实现了显示窗口和处理输入的功能。我们先分析一下目前的程序有哪些问题:

  • 所有逻辑都堆在一个文件中,修改不便

  • 逻辑中直接出现字面值,例如640/480,字符串"外星人入侵"等,每次修改都需要重新编译程序

在继续之前,我们先对代码组织结构做一次重构,这能让我们走得更远。

为了清晰,方便管理,我们逻辑拆分到4个文件中:

  • game.go:编写Game对象,并实现相关方法,同时负责协调其他各个模块

  • input.go:输入相关的逻辑

  • config.go:专门负责配置相关的逻辑

  • main.go:main函数,负责创建Game对象,运行游戏

为了程序的灵活修改,我们将程序中的可变项都作为配置存放在文件中,程序启动时自动读取这个配置文件。我选择json作为配置文件的格式:

{
  "screenWidth": 640,
  "screenHeight": 480,
  "title": "外星人入侵",
  "bgColor": {
    "r": 230,
    "g": 230,
    "b": 230,
    "a": 255
  }
}

然后定义配置的结构和加载配置的函数:

type Config struct {
    ScreenWidth  int        `json:"screenWidth"`
    ScreenHeight int        `json:"screenHeight"`
    Title        string     `json:"title"`
    BgColor      color.RGBA `json:"bgColor"`
}

func loadConfig() *Config {
    f, err := os.Open("./config.json")
    if err != nil {
        log.Fatalf("os.Open failed: %v\n", err)
    }

    var cfg Config
    err = json.NewDecoder(f).Decode(&cfg)
    if err != nil {
    log.Fatalf("json.Decode failed: %v\n", err)
}

    return &cfg
}

将游戏核心逻辑移到game.go文件中,定义游戏对象结构和创建游戏对象的方法:

type Game struct {
  input *Input
  cfg   *Config
}

func NewGame() *Game {
  cfg := loadConfig()
  ebiten.SetWindowSize(cfg.ScreenWidth, cfg.ScreenHeight)
  ebiten.SetWindowTitle(cfg.Title)

  return &Game{
    input: &Input{},
    cfg:   cfg,
  }
}

先从配置文件中加载配置,然后根据配置设置游戏窗口大小和标题。拆分之后,DrawLayout方法实现如下:

func (g *Game) Draw(screen *ebiten.Image) {
  ebitenutil.DebugPrint(screen, "Hello, World!")
  screen.Fill(g.cfg.BgColor)
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
  return g.cfg.ScreenWidth / 2, g.cfg.ScreenHeight / 2
}

第一次重构到此完成,现在来看一下文件结构,是否更清晰了呢?

├── config.go
├── config.json
├── game.go
├── input.go
└── main.go

注意,因为拆分成了多个文件,所以运行程序不能再使用go run main.go命令了,需要改为go run .

显示图片

接下来我们尝试在屏幕底部中心位置显示一张飞船的图片:

b2f7e8dc94b4743526d95abff42b7412.png

ebitengine引擎提供了ebitenutil.NewImageFromFile函数,传入图片路径即可加载该图片,so easy。为了很好的管理游戏中的各个实体,我们给每个实体都定义一个结构。先定义飞船结构:

import (
    _ "golang.org/x/image/bmp"
)

type Ship struct {
  image  *ebiten.Image
  width  int
  height int
}

func NewShip() *Ship {
  img, _, err := ebitenutil.NewImageFromFile("../images/ship.bmp")
  if err != nil {
    log.Fatal(err)
  }

  width, height := img.Size()
  ship := &Ship{
    image:  img,
    width:  width,
    height: height,
  }

  return ship
}

我提供了两种图片格式,一种是png,一种是bmp,用哪种都可以。注意,需要将对应的图片解码包导入。Go标准库提供了三种格式的解码包,image/pngimage/jpegimage/gif。也就是说标准库中没有bmp格式的解码包,所幸golang.org/x仓库没有让我们失望,golang.org/x/image/bmp提供了解析bmp格式图片的功能。我们这里不需要显式的使用对应的图片库,故使用import _这种方式,让init函数产生副作用。

然后在游戏对象中添加飞船类型的字段:

func NewGame() *Game {
  // 相同的代码省略...
  return &Game {
    input:   &Input{},
    ship:  NewShip(),
    cfg:  cfg,
  }
}

为了将飞船显示在屏幕底部中央位置,我们需要计算坐标。ebitengine采用如下所示的二维坐标系:

4bb05ca82a5133d55cb57f7de1f8e3e8.png

x轴向右,y轴向下,左上角为原点。我们需要计算飞船左上角的位置。由上图很容易计算出:

x=(W1-W2)/2
y=H1-H2

为了在屏幕上显示飞船图片,我们需要调用*ebiten.ImageDrawImage方法,该方法的第二个参数可以用于指定坐标相对于原点的偏移:

func (g *Game) Draw(screen *ebiten.Image) {
  screen.Fill(g.cfg.BgColor)
  op := &ebiten.DrawImageOptions{}
  op.GeoM.Translate(float64(g.cfg.ScreenWidth-g.ship.width)/2, float64(g.cfg.ScreenHeight-g.ship.height))
  screen.DrawImage(g.ship.image, op)
}

我们给Ship类型增加一个绘制自身的方法,传入屏幕对象screen和配置,让代码更好维护:

func (ship *Ship) Draw(screen *ebiten.Image, cfg *Config) {
    op := &ebiten.DrawImageOptions{}
    op.GeoM.Translate(float64(cfg.ScreenWidth-ship.width)/2, float64(cfg.ScreenHeight-ship.height))
    screen.DrawImage(ship.image, op)
}

这样游戏对象中的Draw方法就可以简化为:

func (g *Game) Draw(screen *ebiten.Image) {
    screen.Fill(g.cfg.BgColor)
    g.ship.Draw(screen, g.cfg)
}

运行:

7712e51ffa1d3f375dc6774a71699515.png

移动飞船

现在我们来实现使用左右方向键来控制飞船的移动。首先给飞船的类型增加x/y坐标字段:

type Ship struct {
    // 与前面的代码一样
    x float64 // x坐标
    y float64 // y坐标
}

我们前面已经计算出飞船位于屏幕底部中心时的坐标,在创建飞船时将该坐标赋给xy:

func NewShip(screenWidth, screenHeight int) *Ship {
  ship := &Ship{
    // ...
    x: float64(screenWidth-width) / 2,
    y: float64(screenHeight - height),
  }

  return ship
}

由于NewShip计算初始坐标需要屏幕尺寸,故增加屏幕宽、高两个参数,由NewGame方法传入:

func NewGame() *Game {
  // 与上面的代码一样

  return &Game{
    input: &Input{},
    ship:  NewShip(cfg.ScreenWidth, cfg.ScreenHeight),
    cfg:   cfg,
  }
}

然后我们在InputUpdate方法中根据按下的是左方向键还是右方向键来更新飞船的坐标:

type Input struct{}

func (i *Input) Update(ship *Ship) {
  if ebiten.IsKeyPressed(ebiten.KeyLeft) {
    ship.x -= 1
  } else if ebiten.IsKeyPressed(ebiten.KeyRight) {
    ship.x += 1
  }
}

由于需要修改飞船坐标,Game.Update方法调用Input.Update时需要传入飞船对象:

func (g *Game) Update() error {
  g.input.Update(g.ship)
  return nil
}

好了,现在可以运行程序了go run .,效果如下:

3377f4fab0a9c939b71e575988a1535b.gif

注意到,目前有两个问题:

  • 移动太慢

  • 可以移出屏幕

因为现在每次只对x坐标修改1个像素位置,故而显得太慢。我们可以在增加一个飞船速度的配置项来控制每次变化的像素数:

{
  "screenWidth": 640,
  "screenHeight": 480,
  "title": "外星人入侵",
  "bgColor": {
    "r": 230,
    "g": 230,
    "b": 230,
    "a": 255
  },
  "shipSpeedFactor": 3
}

config.go需要相应的修改:

type Config struct {
    // 一样的代码
    ShipSpeedFactor float64    `json:"shipSpeedFactor"`
}

修改Input.Update方法,每次更新ShipSpeedFactor个像素:

func (i *Input) Update(ship *Ship, cfg *Config) {
  if ebiten.IsKeyPressed(ebiten.KeyLeft) {
    ship.x -= cfg.ShipSpeedFactor
  } else if ebiten.IsKeyPressed(ebiten.KeyRight) {
    ship.x += cfg.ShipSpeedFactor
  }
}

因为在Input.Update方法中需要访问配置,因此增加Config类型的参数,由Game.Update方法传入:

func (g *Game) Update() error {
  g.input.Update(g.ship, g.cfg)
  return nil
}

运行,是不是快了很多呢?

2866a4d315b581fd215ce0e3a6ae2570.gif

总结

本文介绍了2D游戏开发库ebiten的基本使用,并开始开发一个外星人入侵的游戏(1/3),目前只能在屏幕上移动飞船,下篇文章继续完成剩下的内容。

大家如果发现好玩、好用的 Go 语言库,欢迎到 Go 每日一库 GitHub 上提交 issue😄

参考

  1. Go 每日一库 GitHub:https://github.com/darjun/go-daily-lib

  2. ebitengine 官网:https://ebitengine.org/

  3. Python 编程(从入门到实践):https://book.douban.com/subject/35196328/

我的博客:https://darjun.github.io

欢迎关注我的微信公众号【GoUpUp】,共同学习,一起进步~

7d9ece4cfc71f5b2329344b0f2b97e59.png

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/68671.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

「Redis数据结构」列表对象(List)

「Redis数据结构」列表对象(List) 文章目录「Redis数据结构」列表对象(List)一、概述二、结构三、编码转换四、总结一、概述 Redis列表是简单的字符串列表,按照插入顺序排序。你可以添加一个元素到列表的头部&#xf…

(附源码)php丽江旅游服务网站系统 毕业设计 010149

php丽江旅游服务网站系统 摘 要 21世纪时信息化的时代,几乎任何一个行业都离不开计算机,将计算机运用于旅游服务管理也是十分常见的。过去使用手工的管理方式对旅游服务进行管理,造成了管理繁琐、难以维护等问题,如今使用计算机对…

APP自动化测试系列之Appium介绍及运行原理

在面试APP自动化时,有的面试官可能会问Appium的运行原理,以下介绍Appium运行原理。 一、Appium介绍 1.Appium概念 Appium是一个开源测试自动化框架,可用于原生,混合和移动Web应用程序测试。它使用WebDriver协议驱动IOS&#xf…

易基因|m6A去甲基化酶ALKBH5通过降低PHF20 mRNA甲基化抑制结直肠癌进展 | 肿瘤研究

易基因|m6A去甲基化酶ALKBH5通过降低PHF20 mRNA甲基化抑制结直肠癌进展 | 肿瘤研究 大家好,这里是专注表观组学十余年,领跑多组学科研服务的易基因。 2022年8月17日,北京大学人民医院胃肠外科申占龙教授课题组在《Clin Transl M…

(附源码)ssm物流公司员工管理系统 毕业设计 261625

基于ssm物流公司员工管理系统 摘 要 随着互联网大趋势的到来,社会的方方面面,各行各业都在考虑利用互联网作为媒介将自己的信息更及时有效地推广出去,而其中最好的方式就是建立网络管理系统,并对其进行信息管理。由于现在网络的发…

LeetCode简单题之按身高排序

题目 给你一个字符串数组 names ,和一个由 互不相同 的正整数组成的数组 heights 。两个数组的长度均为 n 。 对于每个下标 i,names[i] 和 heights[i] 表示第 i 个人的名字和身高。 请按身高 降序 顺序返回对应的名字数组 names 。 示例 1&#xff1…

聚观早报 | 奈雪成乐乐茶第一大股东;达达与抖音达成战略合作

今日要闻:奈雪成乐乐茶第一大股东;达达与抖音达成战略合作;B 站启动新一轮降本增效;特斯拉上海工厂减产20%;大众将从中国向欧出口汽车奈雪成乐乐茶第一大股东 12 月 6 日消息,乐乐茶与奈雪的茶签署5.25亿元…

主成分分析-书后习题回顾总结

7-2 题目 理论基础 矩阵的特征值和特征向量的定义以及其求法 https://www.cnblogs.com/Peyton-Li/p/9772281.html 特征值和特征向量的定义:设AAA是nnn阶方阵,如果数λ\lambdaλ和nnn维非零列向量α\alphaα使关系式AαλαA\alpha\lambda\alphaAαλα成…

MyBatis一 Mybatis的介绍、基本使用、高级使用

一 数据库操作框架的历程 1.1 JDBC JDBC(Java Data Base Connection,java数据库连接)是一种用于执行SQL语句的Java API,可以为多种关系数据库提供统一访问,它由一组用Java语言编写的类和接口组成.JDBC提供了一种基准,据此可以构建更高级的工具和接口,使数据库开发人员能够编写…

【JDBC】----封装工具类和ORM

分享第二十二篇励志语录 有些烦恼是我们凭空虚构的,而我们却把它当成真实去承受。想得太多只会毁了你,让你陷入忐忑,让实际上本不糟糕的事情,变得糟糕。阳光这么好,何必自寻烦恼。 目录 分享第二十二篇励志语录 一&a…

毕业设计 stm32老人跌倒检测预防系统 - 单片机 物联网 嵌入式

文章目录0 前言1 整体设计2 硬件电路3 软件设计4 跌倒检测算法5 关键代码6 最后0 前言 🔥 这两年开始毕业设计和毕业答辩的要求和难度不断提升,传统的毕设题目缺少创新和亮点,往往达不到毕业答辩的要求,这两年不断有学弟学妹告诉…

linux常用命令二

1、find 查找文件或目录 find / -size 204800k //在根目录下查找大于200MB的文件 find / -user username//在根目录下查找所有者为username的文件 find / -name filename.txt //根据名称查找/目录下的filename.txt文件。 2、复制文件包括其子文件到自定目录 cp -r sourceF…

[附源码]JAVA毕业设计水果销售管理网站(系统+LW)

[附源码]JAVA毕业设计水果销售管理网站(系统LW) 项目运行 环境项配置: Jdk1.8 Tomcat8.5 Mysql HBuilderX(Webstorm也行) Eclispe(IntelliJ IDEA,Eclispe,MyEclispe,Sts都支持)。 项目技术…

AidLux智慧交通AI安全实战

这里写目录标题1.项目背景2. 项目实战流程2.1 YOLOv5车辆检测模型训练及部署2.2 AI对抗攻击与对抗防御2.2.1 AI对抗攻击算法讲解2.2.2 常用AI对抗攻击算法划分2.2.3 对抗攻击主要代码2.2.4 对抗攻击效果验证2.2.4 常用AI对抗防御算法讲解2.2.5 常用AI对抗防御算法划分2.2.6 对抗…

实验十一 级数与方程符号求解(MATLAB)

实验十一 级数与方程符号求解 1.1实验目的 1.2实验内容 1.3流程图 1.4程序清单 1.5运行结果及分析 1.6实验的收获与体会 1.1实验目的 1.2实验内容 实验十一 级数与方程符号求解 课本373页 1.3流程图 1.4程序清单 实验十一 1 clear clc nsym(n);xsym(x); s1…

通关算法题之 ⌈字符串⌋

字符串 171. Excel 表列序号 给你一个字符串 columnTitle ,表示 Excel 表格中的列名称,返回该列名称对应的列序号。 A -> 1 B -> 2 C -> 3 ... Z -> 26 AA -> 27 AB -> 28 ...输入: columnTitle "A" 输出: 1 输入: col…

一起用Go做一个小游戏(中)

限制飞船的活动范围上一篇文章还留了个尾巴,细心的同学应该发现了:飞船可以移动出屏幕!!!现在我们就来限制一下飞船的移动范围。我们规定飞船可以左右超过半个身位,如下图所示:很容易计算得出&a…

vue3+ts打包报错处理

打包报错 但是npm run dev 是运行正常的 经过一番搜索之后,这个错误是比较难搜索到的 注意看package.json 中的vue-tsc --noEmit 删掉就可以了 { “name”: “vuevitec”, “version”: “0.0.0”, “scripts”: { “dev”: “vite”, “build”: “vue-tsc --noEm…

NextJs 学习笔记

NextJs 学习笔记 简述 之前使用过 Nuxt3 基于前端框架 Vue3 来开发网站,因为 Nuxt3 很多地方借鉴了基于 React 的 SSR 框架 Next,因此最近抽时间开始学习一下 Next 这个框架。 创建项目 npx create-next-applatest # or yarn create next-app # or p…

大学生川菜网页制作教程 学生HTML静态美食菜品网页设计作业成品 简单网页制作代码 学生美食网页作品免费设计

🎀 精彩专栏推荐👇🏻👇🏻👇🏻 ✍️ 作者简介: 一个热爱把逻辑思维转变为代码的技术博主 💂 作者主页: 【主页——🚀获取更多优质源码】 🎓 web前端期末大作业…