├── .gitignore ├── readme ├── image.png ├── image-1.png ├── image-2.png ├── image-3.png ├── image-4.png ├── image-5.png ├── image-6.png └── snapshoot.gif ├── resource ├── bug.png ├── dog.jpeg ├── iron.png ├── leaves.png ├── logo.png ├── water.png ├── camo_net.png ├── brown_tank.png ├── explosion.png ├── green_tank.png ├── play_button.png ├── projectile.png ├── exit_game_button.png ├── Brick_Block_small.png ├── brown_tank_turret.png └── green_tank_turret.png ├── package ├── utils │ ├── sound │ │ ├── 1.mp3 │ │ ├── 2.mp3 │ │ ├── 3.mp3 │ │ ├── 4.mp3 │ │ ├── 5.mp3 │ │ ├── bgm.mp3 │ │ ├── boom.wav │ │ ├── dead1.mp3 │ │ ├── dead2.wav │ │ ├── dead3.wav │ │ ├── dead4.mp3 │ │ ├── dog.wav │ │ ├── yiwai.wav │ │ └── sound.go │ ├── variable.go │ └── utils.go ├── monitor │ └── screen.go ├── tank │ ├── check_test.go │ ├── keyboard.go │ ├── gameover.go │ ├── name.go │ ├── npcmove.go │ ├── menu.go │ ├── check.go │ ├── barrier.go │ └── tank.go └── game │ └── game.go ├── go.mod ├── main.go ├── README.md └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | .VSCodeCounter -------------------------------------------------------------------------------- /readme/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/readme/image.png -------------------------------------------------------------------------------- /resource/bug.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/resource/bug.png -------------------------------------------------------------------------------- /readme/image-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/readme/image-1.png -------------------------------------------------------------------------------- /readme/image-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/readme/image-2.png -------------------------------------------------------------------------------- /readme/image-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/readme/image-3.png -------------------------------------------------------------------------------- /readme/image-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/readme/image-4.png -------------------------------------------------------------------------------- /readme/image-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/readme/image-5.png -------------------------------------------------------------------------------- /readme/image-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/readme/image-6.png -------------------------------------------------------------------------------- /resource/dog.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/resource/dog.jpeg -------------------------------------------------------------------------------- /resource/iron.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/resource/iron.png -------------------------------------------------------------------------------- /resource/leaves.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/resource/leaves.png -------------------------------------------------------------------------------- /resource/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/resource/logo.png -------------------------------------------------------------------------------- /resource/water.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/resource/water.png -------------------------------------------------------------------------------- /readme/snapshoot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/readme/snapshoot.gif -------------------------------------------------------------------------------- /resource/camo_net.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/resource/camo_net.png -------------------------------------------------------------------------------- /resource/brown_tank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/resource/brown_tank.png -------------------------------------------------------------------------------- /resource/explosion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/resource/explosion.png -------------------------------------------------------------------------------- /resource/green_tank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/resource/green_tank.png -------------------------------------------------------------------------------- /resource/play_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/resource/play_button.png -------------------------------------------------------------------------------- /resource/projectile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/resource/projectile.png -------------------------------------------------------------------------------- /package/utils/sound/1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/package/utils/sound/1.mp3 -------------------------------------------------------------------------------- /package/utils/sound/2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/package/utils/sound/2.mp3 -------------------------------------------------------------------------------- /package/utils/sound/3.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/package/utils/sound/3.mp3 -------------------------------------------------------------------------------- /package/utils/sound/4.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/package/utils/sound/4.mp3 -------------------------------------------------------------------------------- /package/utils/sound/5.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/package/utils/sound/5.mp3 -------------------------------------------------------------------------------- /package/utils/sound/bgm.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/package/utils/sound/bgm.mp3 -------------------------------------------------------------------------------- /package/utils/sound/boom.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/package/utils/sound/boom.wav -------------------------------------------------------------------------------- /package/utils/sound/dead1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/package/utils/sound/dead1.mp3 -------------------------------------------------------------------------------- /package/utils/sound/dead2.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/package/utils/sound/dead2.wav -------------------------------------------------------------------------------- /package/utils/sound/dead3.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/package/utils/sound/dead3.wav -------------------------------------------------------------------------------- /package/utils/sound/dead4.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/package/utils/sound/dead4.mp3 -------------------------------------------------------------------------------- /package/utils/sound/dog.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/package/utils/sound/dog.wav -------------------------------------------------------------------------------- /package/utils/sound/yiwai.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/package/utils/sound/yiwai.wav -------------------------------------------------------------------------------- /resource/exit_game_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/resource/exit_game_button.png -------------------------------------------------------------------------------- /resource/Brick_Block_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/resource/Brick_Block_small.png -------------------------------------------------------------------------------- /resource/brown_tank_turret.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/resource/brown_tank_turret.png -------------------------------------------------------------------------------- /resource/green_tank_turret.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gofish2020/tankgame/HEAD/resource/green_tank_turret.png -------------------------------------------------------------------------------- /package/monitor/screen.go: -------------------------------------------------------------------------------- 1 | package monitor 2 | 3 | import "github.com/hajimehoshi/ebiten/v2" 4 | 5 | var ( 6 | ScreenWidth float64 7 | ScreenHeight float64 8 | ) 9 | 10 | func init() { 11 | w, h := ebiten.Monitor().Size() 12 | ScreenWidth, ScreenHeight = float64(w), float64(h) 13 | } 14 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/gofish2020/tankgame 2 | 3 | go 1.22.4 4 | 5 | require ( 6 | github.com/hajimehoshi/ebiten/v2 v2.7.5 7 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 8 | ) 9 | 10 | require ( 11 | github.com/ebitengine/gomobile v0.0.0-20240518074828-e86332849895 // indirect 12 | github.com/ebitengine/hideconsole v1.0.0 // indirect 13 | github.com/ebitengine/oto/v3 v3.2.0 // indirect 14 | github.com/ebitengine/purego v0.7.0 // indirect 15 | github.com/go-text/typesetting v0.1.1-0.20240325125605-c7936fe59984 // indirect 16 | github.com/hajimehoshi/go-mp3 v0.3.4 // indirect 17 | github.com/jezek/xgb v1.1.1 // indirect 18 | golang.org/x/image v0.16.0 // indirect 19 | golang.org/x/sync v0.7.0 // indirect 20 | golang.org/x/sys v0.20.0 // indirect 21 | golang.org/x/text v0.15.0 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /package/tank/check_test.go: -------------------------------------------------------------------------------- 1 | package tank 2 | 3 | import "testing" 4 | 5 | func Test_checkCollision1(t *testing.T) { 6 | type args struct { 7 | point Point 8 | vertices []Point 9 | } 10 | 11 | tests := []struct { 12 | name string 13 | args args 14 | want bool 15 | }{ 16 | // TODO: Add test cases. 17 | { 18 | name: "test", 19 | args: args{ 20 | point: Point{ 21 | X: 1, 22 | Y: 2, 23 | }, 24 | vertices: []Point{{2, 1}, {2, 2}, {3, 2}, {3, 1}}, 25 | }, 26 | want: false, 27 | }, 28 | { 29 | name: "test", 30 | args: args{ 31 | point: Point{ 32 | X: 2.5, 33 | Y: 1.5, 34 | }, 35 | vertices: []Point{{2, 1}, {2, 2}, {3, 2}, {3, 1}}, 36 | }, 37 | want: true, 38 | }, 39 | } 40 | for _, tt := range tests { 41 | t.Run(tt.name, func(t *testing.T) { 42 | if got := checkCollision1(tt.args.point, tt.args.vertices); got != tt.want { 43 | t.Errorf("checkCollision1() = %v, want %v", got, tt.want) 44 | } 45 | }) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/gofish2020/tankgame/package/game" 7 | "github.com/gofish2020/tankgame/package/monitor" 8 | "github.com/hajimehoshi/ebiten/v2" 9 | "github.com/hajimehoshi/ebiten/v2/audio" 10 | ) 11 | 12 | func main() { 13 | 14 | ebiten.SetRunnableOnUnfocused(true) // 游戏界面不显示,依然运行 15 | ebiten.SetScreenClearedEveryFrame(false) 16 | ebiten.SetTPS(50) // 窗口刷新频率 17 | ebiten.SetVsyncEnabled(true) // 垂直同步 18 | ebiten.SetWindowDecorated(false) 19 | ebiten.SetWindowTitle("Tank Shoot") 20 | ebiten.SetWindowSize(int(monitor.ScreenWidth), int(monitor.ScreenHeight)) 21 | ebiten.SetWindowFloating(true) // 置顶显示 22 | ebiten.SetWindowMousePassthrough(false) // 鼠标穿透 23 | 24 | // 需要提前调用一下,不然没有声音 25 | audio.NewContext(44100) 26 | audio.CurrentContext().NewPlayerFromBytes([]byte{}).Play() // 类似于预热的感觉(可能是库有bug) 27 | 28 | game := game.NewGame() 29 | err := ebiten.RunGameWithOptions(game, &ebiten.RunGameOptions{ 30 | InitUnfocused: true, // 启动时候,窗体不聚焦 31 | ScreenTransparent: true, // 窗体透明 32 | SkipTaskbar: true, // 图片不显示在任务栏 33 | X11ClassName: "Tank Shoot", 34 | X11InstanceName: "Tank Shoot", 35 | }) 36 | if err != nil { 37 | log.Fatal(err) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /package/utils/variable.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | var ( 4 | GameProgress = "init" // init 初始界面 prepare 数据准备中 next 下一关 play 游戏进行中 over 游戏over pass 通关 5 | GameLevel = 0 // 游戏关卡 6 | KilledCount = 0 7 | 8 | MaxGameLevel = 4 // 最多的关卡数 9 | FullMap = false 10 | ) 11 | 12 | type TankLevel struct { 13 | TankSpeed float64 14 | TurrentRotateSpeed float64 15 | } 16 | 17 | var ( 18 | TankLevels = []TankLevel{ 19 | {TankSpeed: 3, TurrentRotateSpeed: 1.0}, 20 | {TankSpeed: 3, TurrentRotateSpeed: 2.0}, 21 | {TankSpeed: 3, TurrentRotateSpeed: 3.0}, 22 | {TankSpeed: 3, TurrentRotateSpeed: 4.0}, 23 | {TankSpeed: 3, TurrentRotateSpeed: 5.0}, 24 | {TankSpeed: 3, TurrentRotateSpeed: 6.0}, 25 | 26 | {TankSpeed: 4.0, TurrentRotateSpeed: 1.0}, 27 | {TankSpeed: 4.0, TurrentRotateSpeed: 2.0}, 28 | {TankSpeed: 4.0, TurrentRotateSpeed: 3.0}, 29 | {TankSpeed: 4.0, TurrentRotateSpeed: 4.0}, 30 | {TankSpeed: 4.0, TurrentRotateSpeed: 5.0}, 31 | {TankSpeed: 4.0, TurrentRotateSpeed: 6.0}, 32 | 33 | {TankSpeed: 5, TurrentRotateSpeed: 1.0}, 34 | {TankSpeed: 5, TurrentRotateSpeed: 2.0}, 35 | {TankSpeed: 5, TurrentRotateSpeed: 3.0}, 36 | {TankSpeed: 5, TurrentRotateSpeed: 4.0}, 37 | {TankSpeed: 5, TurrentRotateSpeed: 5.0}, 38 | {TankSpeed: 5, TurrentRotateSpeed: 6.0}, 39 | 40 | {TankSpeed: 2.0, TurrentRotateSpeed: 10.0}, 41 | {TankSpeed: 3.0, TurrentRotateSpeed: 10.0}, 42 | 43 | {TankSpeed: 5.0, TurrentRotateSpeed: 10.0}, 44 | {TankSpeed: 8.0, TurrentRotateSpeed: 1.0}, 45 | } 46 | ) 47 | -------------------------------------------------------------------------------- /package/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | 7 | "github.com/hajimehoshi/ebiten/v2" 8 | "github.com/hajimehoshi/ebiten/v2/vector" 9 | ) 10 | 11 | var ( 12 | whiteImage = ebiten.NewImage(3, 3) 13 | whiteSubImage = whiteImage.SubImage(image.Rect(1, 1, 2, 2)).(*ebiten.Image) 14 | ) 15 | 16 | func init() { 17 | b := whiteImage.Bounds() 18 | pix := make([]byte, 4*b.Dx()*b.Dy()) 19 | for i := range pix { 20 | pix[i] = 0xff 21 | } 22 | // This is hacky, but WritePixels is better than Fill in term of automatic texture packing. 23 | whiteImage.WritePixels(pix) 24 | } 25 | 26 | // 绘制扇形 27 | func DrawSector(screen *ebiten.Image, x, y float32, lineWidth float32, radius float32, startAngle, endAngle float32, clr color.Color, isFill bool) { 28 | var path vector.Path 29 | 30 | //theta2 := math.Pi * float64(count) / 180 / 3 31 | path.MoveTo(x, y) 32 | path.Arc(x, y, radius, startAngle, endAngle, vector.Clockwise) 33 | path.Close() 34 | 35 | var vs []ebiten.Vertex 36 | var is []uint16 37 | if !isFill { 38 | op := &vector.StrokeOptions{} 39 | op.Width = lineWidth 40 | op.LineJoin = vector.LineJoinRound 41 | vs, is = path.AppendVerticesAndIndicesForStroke(nil, nil, op) 42 | } else { 43 | vs, is = path.AppendVerticesAndIndicesForFilling(nil, nil) 44 | } 45 | 46 | r, g, b, a := clr.RGBA() 47 | for i := range vs { 48 | vs[i].SrcX = 1 49 | vs[i].SrcY = 1 50 | vs[i].ColorR = float32(r) / 0xffff 51 | vs[i].ColorG = float32(g) / 0xffff 52 | vs[i].ColorB = float32(b) / 0xffff 53 | vs[i].ColorA = float32(a) / 0xffff 54 | } 55 | 56 | op := &ebiten.DrawTrianglesOptions{} 57 | op.AntiAlias = true 58 | op.FillRule = ebiten.FillAll 59 | screen.DrawTriangles(vs, is, whiteSubImage, op) 60 | } 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Golang 实现坦克世界 2 | 3 | 4 | 项目地址: https://github.com/gofish2020/tankgame 欢迎Fork && Star 5 | 6 | 7 | ## 游戏效果 8 | 9 | 本项目基于游戏引擎 `Ebitengine` 开发,这里有很多的实例 https://ebiten-zh.vercel.app/examples/ 便于边学边用 10 | 11 | 12 | 13 | ![](readme/snapshoot.gif) 14 | 15 | 16 | 程序下载到本地,直接 `go run main.go`即可看效果。 17 | - 开发使用的`go1.20`版本 18 | - 如果你是`Linux` or `MacOS`需要安装 `C`语言编译器,安装教程 https://ebiten-zh.vercel.app/documents/install.html 19 | 20 | ## 代码结构 21 | 22 | ```go 23 | tankgame 24 | ├── go.mod 25 | ├── go.sum 26 | ├── main.go 入口函数 27 | ├── package 28 | │ ├── game 29 | │ │ └── game.go 基于全局参数,游戏进度逻辑处理 30 | │ ├── monitor 31 | │ │ └── screen.go 显示器的宽+高获取 32 | │ ├── tank 33 | │ │ ├── barrier.go 障碍物的创建 + 绘制 34 | │ │ ├── check.go 碰撞检测 35 | │ │ ├── gameover.go 游戏结束画面 36 | │ │ ├── keyboard.go 坦克周围字体绘制 37 | │ │ ├── menu.go 游戏开始界面 38 | │ │ ├── npcmove.go npc的移动 + 敌人搜索 39 | │ │ ├── name.go 坦克的名字 40 | │ │ └── tank.go 每个坦克的绘制 + 逻辑处理 41 | │ └── utils 42 | │ ├── sound 43 | │ │ ├── sound.go 音频处理 44 | │ ├── utils.go 扇形绘制 45 | │ └── variable.go 全局参数(控制游戏进度) 46 | ├── resource 图片资源 47 | 48 | 49 | ``` 50 | 51 | 代码中有大量注释,直接从 `type Game struct `结构体中的 `Update`(负责数据的更新) 和`Draw`(负责界面绘制)两个函数开始看 52 | 53 | 54 | 55 | ## 数学知识补充 56 | 57 | 这些做碰撞检测需要用的知识点 58 | 59 | **旋转矩阵** 60 | 用来计算坐标点经过旋转后的新的坐标 61 | 62 | ![](./readme/image.png) 63 | ![](./readme/image-1.png) 64 | 65 | **叉积公式** 66 | 67 | 可以用来判断点是否在多边形内部; 68 | 69 | 代码的中的坐标是向下为Y轴,向右为X轴,所以这里的左边(逆时针),变成顺时针;这里的右边(顺时针),变成了逆时针。 70 | 71 | 72 | ![](./readme/image-2.png) 73 | 74 | ![](./readme/image-6.png) 75 | 76 | **点积公式** 77 | 78 | 用来做向量投影计算 79 | 80 | ![](./readme/image-5.png) 81 | 82 | **分离轴定理** 83 | 用来做矩形是否相交的算法 84 | 85 | 找多边形的每条边的法向量(然后作为轴),让每个多边形的顶点向量(计算点积)向轴进行投影;判断投影是否有重叠。。只要出现一个不重叠的轴,就说明多边形不相交 86 | ![](./readme/image-3.png) 87 | 88 | ![](./readme/image-4.png) -------------------------------------------------------------------------------- /package/tank/keyboard.go: -------------------------------------------------------------------------------- 1 | package tank 2 | 3 | import ( 4 | "bytes" 5 | _ "embed" 6 | "image/color" 7 | "log" 8 | "math" 9 | 10 | "github.com/hajimehoshi/ebiten/v2" 11 | "github.com/hajimehoshi/ebiten/v2/examples/resources/fonts" 12 | "github.com/hajimehoshi/ebiten/v2/text/v2" 13 | ) 14 | 15 | var ( 16 | mplusNormalFont *text.GoTextFaceSource 17 | ) 18 | 19 | func init() { 20 | 21 | s, err := text.NewGoTextFaceSource(bytes.NewReader(fonts.MPlus1pRegular_ttf)) 22 | if err != nil { 23 | log.Fatal(err) 24 | } 25 | mplusNormalFont = s 26 | } 27 | 28 | // 绘制坦克周围的 29 | func KeyPressDrawAroundTank(t *Tank, screen *ebiten.Image) { 30 | 31 | op := &text.DrawOptions{} 32 | 33 | op.ColorScale.ScaleWithColor(color.RGBA{210, 105, 30, 255}) 34 | keyWord := "" 35 | x, y := 0.0, 0.0 36 | if ebiten.IsKeyPressed(ebiten.KeyW) { 37 | x, y = -5.0, -25.0 38 | keyWord = "W" 39 | } else if ebiten.IsKeyPressed(ebiten.KeyS) { 40 | x, y = -5.0, 25.0 41 | keyWord = "S" 42 | } 43 | 44 | op.GeoM.Translate(x, y) 45 | angleRad := (t.Angle + 90) * math.Pi / 180.0 // 角度转弧度 (因为坦克头默认是向右,转-90度才是正向,此时的字体是不用转的,所以需要+90消掉转的效果) 46 | op.GeoM.Rotate(angleRad) 47 | // x,y 经过旋转 angleRad 角度后的位置坐标 x1,y1 48 | x1, y1 := x*math.Cos(angleRad)-y*math.Sin(angleRad), x*math.Sin(angleRad)+y*math.Cos(angleRad) 49 | //op.LineSpacing = 100 50 | op.GeoM.Translate(x1+t.X, y1+t.Y) 51 | text.Draw(screen, keyWord, &text.GoTextFace{ 52 | Source: mplusNormalFont, 53 | Size: 20}, op) 54 | 55 | // 重置 56 | op.GeoM.Reset() 57 | if ebiten.IsKeyPressed(ebiten.KeyA) { 58 | x, y = -30.0, -5.0 59 | keyWord = "A" 60 | } else if ebiten.IsKeyPressed(ebiten.KeyD) { 61 | x, y = 20.0, -5.0 62 | keyWord = "D" 63 | } 64 | 65 | op.GeoM.Translate(x, y) 66 | op.GeoM.Rotate(angleRad) 67 | x1, y1 = x*math.Cos(angleRad)-y*math.Sin(angleRad), x*math.Sin(angleRad)+y*math.Cos(angleRad) 68 | op.LineSpacing = 100 69 | op.GeoM.Translate(x1+t.X, y1+t.Y) 70 | text.Draw(screen, keyWord, &text.GoTextFace{ 71 | Source: mplusNormalFont, 72 | Size: 20}, op) 73 | } 74 | 75 | 76 | -------------------------------------------------------------------------------- /package/tank/gameover.go: -------------------------------------------------------------------------------- 1 | package tank 2 | 3 | import ( 4 | "image/color" 5 | "os" 6 | 7 | "github.com/gofish2020/tankgame/package/monitor" 8 | "github.com/gofish2020/tankgame/package/utils" 9 | "github.com/hajimehoshi/ebiten/v2" 10 | "github.com/hajimehoshi/ebiten/v2/examples/resources/fonts" 11 | "github.com/hajimehoshi/ebiten/v2/text/v2" 12 | "golang.org/x/image/font" 13 | "golang.org/x/image/font/opentype" 14 | ) 15 | 16 | var ( 17 | mplusNormalFontFace font.Face 18 | ) 19 | 20 | func init() { 21 | tt, _ := opentype.Parse(fonts.MPlus1pRegular_ttf) 22 | mplusNormalFontFace, _ = opentype.NewFace(tt, &opentype.FaceOptions{ 23 | Size: 1, 24 | DPI: 100, 25 | }) 26 | } 27 | func GameOverDraw(screen *ebiten.Image) { 28 | 29 | if utils.GameProgress == "over" { 30 | screen.Fill(color.Black) 31 | 32 | bounds, _ := font.BoundString(mplusNormalFontFace, "Game Over") 33 | width := float64(bounds.Max.X - bounds.Min.X) 34 | 35 | op := &text.DrawOptions{} 36 | op.ColorScale.ScaleWithColor(color.White) 37 | op.GeoM.Translate(monitor.ScreenWidth/2-width/2.0, monitor.ScreenHeight/2-90) 38 | 39 | text.Draw(screen, "Game Over", &text.GoTextFace{ 40 | Source: mplusNormalFont, 41 | Size: 90}, op) 42 | 43 | op.GeoM.Reset() 44 | bounds, _ = font.BoundString(mplusNormalFontFace, "Press [Enter] to try again") 45 | width = float64(bounds.Max.X - bounds.Min.X) 46 | op.GeoM.Translate(monitor.ScreenWidth/2-width/2.0, monitor.ScreenHeight/2) 47 | text.Draw(screen, "Press [Enter] to try again", &text.GoTextFace{ 48 | Source: mplusNormalFont, 49 | Size: 90}, op) 50 | 51 | op.GeoM.Reset() 52 | bounds, _ = font.BoundString(mplusNormalFontFace, "Press [ESC] to exit game") 53 | width = float64(bounds.Max.X - bounds.Min.X) 54 | op.GeoM.Translate(monitor.ScreenWidth/2-width/2.0, monitor.ScreenHeight/2+90) 55 | text.Draw(screen, "Press [ESC] to exit game", &text.GoTextFace{ 56 | Source: mplusNormalFont, 57 | Size: 90}, op) 58 | } 59 | 60 | } 61 | 62 | func GameOverUpdate() { 63 | 64 | if utils.GameProgress == "over" { 65 | if ebiten.IsKeyPressed(ebiten.KeyEnter) { 66 | utils.GameProgress = "prepare" 67 | } else if ebiten.IsKeyPressed(ebiten.KeyEscape) { 68 | os.Exit(0) 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /package/utils/sound/sound.go: -------------------------------------------------------------------------------- 1 | package sound 2 | 3 | import ( 4 | "bytes" 5 | "embed" 6 | _ "embed" 7 | "io" 8 | "io/fs" 9 | "log" 10 | "path/filepath" 11 | "strings" 12 | 13 | "github.com/hajimehoshi/ebiten/v2/audio" 14 | "github.com/hajimehoshi/ebiten/v2/audio/mp3" 15 | "github.com/hajimehoshi/ebiten/v2/audio/wav" 16 | ) 17 | 18 | var ( 19 | currentplayer *audio.Player = nil 20 | 21 | bgmPlayer *audio.Player = nil 22 | 23 | mSound map[string][]byte 24 | 25 | //go:embed * 26 | f embed.FS 27 | ) 28 | 29 | func init() { 30 | mSound = make(map[string][]byte) 31 | loadSound() 32 | } 33 | 34 | // 加载音频文件 35 | func loadSound() { 36 | 37 | a, _ := fs.ReadDir(f, ".") 38 | 39 | for _, v := range a { 40 | // 读取文件内容 41 | data, _ := f.ReadFile(v.Name()) 42 | 43 | // 去掉文件名后缀(只剩下文件名) 44 | name := strings.TrimSuffix(v.Name(), filepath.Ext(v.Name())) 45 | // 文件后缀 46 | ext := filepath.Ext(v.Name()) 47 | 48 | switch ext { 49 | case ".mp3": 50 | 51 | mp3Stream, err := mp3.DecodeWithSampleRate(44100, bytes.NewReader(data)) 52 | if err != nil { 53 | log.Fatal(err) 54 | } 55 | // 这里的data 应该是pcm原始数据 56 | data, err := io.ReadAll(mp3Stream) 57 | if err != nil { 58 | log.Fatal(err) 59 | } 60 | mSound[name] = data 61 | case ".wav": 62 | stream, err := wav.DecodeWithSampleRate(44100, bytes.NewReader(data)) 63 | if err != nil { 64 | log.Fatal(err) 65 | } 66 | // 这里的data 应该是pcm原始数据 67 | data, err := io.ReadAll(stream) 68 | if err != nil { 69 | log.Fatal(err) 70 | } 71 | 72 | mSound[name] = data 73 | } 74 | } 75 | } 76 | 77 | func PlaySound(s string) { 78 | 79 | pcm, ok := mSound[s] 80 | if ok { 81 | if currentplayer != nil && currentplayer.IsPlaying() { 82 | currentplayer.Close() 83 | } 84 | currentplayer = audio.CurrentContext().NewPlayerFromBytes(pcm) 85 | currentplayer.SetVolume(.5) 86 | currentplayer.Play() 87 | } 88 | } 89 | 90 | func PlayBGM() { 91 | 92 | if bgmPlayer != nil && bgmPlayer.IsPlaying() { 93 | return 94 | } 95 | 96 | if bgmPlayer != nil { 97 | bgmPlayer.Close() 98 | } 99 | bgmPlayer = audio.CurrentContext().NewPlayerFromBytes(mSound["bgm"]) 100 | bgmPlayer.SetVolume(.2) 101 | bgmPlayer.Play() 102 | } 103 | -------------------------------------------------------------------------------- /package/tank/name.go: -------------------------------------------------------------------------------- 1 | package tank 2 | 3 | import ( 4 | "image/color" 5 | "time" 6 | 7 | "github.com/hajimehoshi/ebiten/v2" 8 | "github.com/hajimehoshi/ebiten/v2/examples/resources/fonts" 9 | "github.com/hajimehoshi/ebiten/v2/text/v2" 10 | "golang.org/x/image/font" 11 | "golang.org/x/image/font/opentype" 12 | ) 13 | 14 | var ( 15 | 16 | // 坦克名字 17 | enemyNames = []string{"Albert", "Allen", "Bert", "Bob", 18 | "Cecil", "Clarence", "Elliot", "Elmer", 19 | "Ernie", "Eugene", "Fergus", "Ferris", 20 | "Frank", "Frasier", "Fred", "George", 21 | "Graham", "Harvey", "Irwin", "Larry", 22 | "Lester", "Marvin", "Neil", "Niles", 23 | "Oliver", "Opie", "Ryan", "Toby", 24 | "Ulric", "Ulysses", "Uri", "Waldo", 25 | "Wally", "Walt", "Wesley", "Yanni", 26 | "Yogi", "Yuri"} 27 | ) 28 | 29 | type killedName struct { 30 | name string 31 | updateTime time.Time 32 | } 33 | 34 | var ( 35 | killedNames = make([]killedName, 0, 3) 36 | ) 37 | 38 | // 更新名字列表 39 | func UpdateNameList(name string) { 40 | 41 | kn := killedName{ 42 | name: name, 43 | updateTime: time.Now(), 44 | } 45 | killedNames = append(killedNames, kn) 46 | } 47 | 48 | // 绘制名字列表 49 | func DrawNameList(screen *ebiten.Image) { 50 | 51 | x, y := 0., 25. 52 | for i, killeName := range killedNames { 53 | if time.Since(killeName.updateTime) > 5*time.Second { // 显示 5s 54 | killedNames = append(killedNames[:i], killedNames[i+1:]...) // 去掉 55 | continue 56 | } 57 | drawName(screen, x, y, killeName.name) 58 | y += 50. 59 | } 60 | } 61 | 62 | var ( 63 | mplusSmallFontFace font.Face 64 | ) 65 | 66 | func init() { 67 | tt, _ := opentype.Parse(fonts.MPlus1pRegular_ttf) 68 | mplusSmallFontFace, _ = opentype.NewFace(tt, &opentype.FaceOptions{ 69 | Size: 1, 70 | DPI: 72, 71 | }) 72 | } 73 | func drawName(screen *ebiten.Image, x, y float64, txt string) { 74 | op := &text.DrawOptions{} 75 | op.ColorScale.ScaleWithColor(color.RGBA{255, 97, 3, 255}) 76 | op.GeoM.Translate(x, y) 77 | 78 | text.Draw(screen, txt, &text.GoTextFace{ 79 | Source: mplusNormalFont, 80 | Size: 50}, op) 81 | 82 | op.GeoM.Reset() 83 | op.ColorScale.Reset() 84 | 85 | bounds, _ := font.BoundString(mplusSmallFontFace, txt) 86 | width := float64(bounds.Max.X - bounds.Min.X) 87 | 88 | op.ColorScale.ScaleWithColor(color.White) 89 | op.GeoM.Translate(x+width, y) 90 | text.Draw(screen, " is killed", &text.GoTextFace{ 91 | Source: mplusNormalFont, 92 | Size: 50}, op) 93 | } 94 | -------------------------------------------------------------------------------- /package/tank/npcmove.go: -------------------------------------------------------------------------------- 1 | package tank 2 | 3 | import "math" 4 | 5 | // 坦克移动 6 | type TankPosition struct { 7 | X float64 8 | Y float64 9 | TK *Tank 10 | } 11 | 12 | // 让npc坦克,检测在攻击范围内的 player 并且朝 player移动 13 | func MoveAndFineEnemyTank(playerPosition TankPosition, npcPositions []TankPosition) { 14 | 15 | for _, npcPosition := range npcPositions { 16 | 17 | npcPosition.TK.Enemy = nil // 默认无敌人 18 | 19 | x := playerPosition.X - npcPosition.X 20 | y := playerPosition.Y - npcPosition.Y 21 | distance := math.Sqrt(x*x + y*y) 22 | 23 | if npcPosition.TK.Turrent.RangeDistance >= distance { // 在攻击范围内 24 | 25 | // 在视野内 26 | angle := math.Atan2(y, x) * 180 / math.Pi 27 | if angle < 0 { 28 | angle += 360.0 29 | } 30 | 31 | // 炮塔的视角范围(也就是扇形两条边的夹角) 32 | startAngle, endAngle := npcPosition.TK.Turrent.Angle-npcPosition.TK.Turrent.RangeAngle, npcPosition.TK.Turrent.Angle+npcPosition.TK.Turrent.RangeAngle 33 | 34 | if endAngle > 360 { 35 | endAngle -= 360 36 | } 37 | if startAngle < 0 { 38 | startAngle += 360 39 | } 40 | 41 | // 正常情况下 startAngle <= endAngle 42 | if startAngle <= endAngle { 43 | if startAngle <= angle && angle <= endAngle { 44 | npcPosition.TK.Enemy = playerPosition.TK 45 | } 46 | } else { 47 | // 如果处于 0 or 360的分割位置,startAngle > endAngle 48 | if angle <= endAngle || angle >= startAngle { 49 | npcPosition.TK.Enemy = playerPosition.TK 50 | } 51 | } 52 | } 53 | 54 | // 说明npc视野内没有敌人 55 | if npcPosition.TK.Enemy == nil { 56 | 57 | // 炮塔扫描 58 | npcPosition.TK.AddTurrentAngle(npcPosition.TK.Turrent.RotationSpeed) 59 | 60 | // 坦克移动方向:转向player的方向 61 | angle := math.Atan2(y, x) * 180 / math.Pi 62 | if angle < 0 { 63 | angle += 360.0 64 | } 65 | 66 | // npcPosition.TK.Angle 表示 坦克 和 x 轴的夹角 67 | // angle 表示两个坦克连线 和 x轴的夹角 68 | if npcPosition.TK.Angle > angle { 69 | // 目的让 npcPosition.TK.Angle 往夹角小的方向移动,让炮台尽可能快的对准敌人 70 | if npcPosition.TK.Angle-angle > 180 { 71 | npcPosition.TK.AddTankAngle(1) 72 | } else { 73 | npcPosition.TK.AddTankAngle(-1) 74 | } 75 | } else if npcPosition.TK.Angle < angle { 76 | 77 | if angle-npcPosition.TK.Angle > 180 { 78 | npcPosition.TK.AddTankAngle(-1) 79 | } else { 80 | npcPosition.TK.AddTankAngle(1) 81 | } 82 | } 83 | 84 | npcPosition.TK.PreX, npcPosition.TK.PreY = npcPosition.TK.X, npcPosition.TK.Y 85 | // 移动坦克 86 | npcPosition.TK.X += npcPosition.TK.ForwardSpeed * math.Cos(npcPosition.TK.Angle*math.Pi/180) 87 | npcPosition.TK.Y += npcPosition.TK.ForwardSpeed * math.Sin(npcPosition.TK.Angle*math.Pi/180) 88 | // 更新碰撞盒子 89 | npcPosition.TK.updateTankCollisionBox() 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/ebitengine/gomobile v0.0.0-20240518074828-e86332849895 h1:48bCqKTuD7Z0UovDfvpCn7wZ0GUZ+yosIteNDthn3FU= 2 | github.com/ebitengine/gomobile v0.0.0-20240518074828-e86332849895/go.mod h1:XZdLv05c5hOZm3fM2NlJ92FyEZjnslcMcNRrhxs8+8M= 3 | github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE= 4 | github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A= 5 | github.com/ebitengine/oto/v3 v3.2.0 h1:FuggTJTSI3/3hEYwZEIN0CZVXYT29ZOdCu+z/f4QjTw= 6 | github.com/ebitengine/oto/v3 v3.2.0/go.mod h1:dOKXShvy1EQbIXhXPFcKLargdnFqH0RjptecvyAxhyw= 7 | github.com/ebitengine/purego v0.7.0 h1:HPZpl61edMGCEW6XK2nsR6+7AnJ3unUxpTZBkkIXnMc= 8 | github.com/ebitengine/purego v0.7.0/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ= 9 | github.com/go-text/typesetting v0.1.1-0.20240325125605-c7936fe59984 h1:NwCC36eQsDf1xVZG9jD7ngXNNjsvk8KXky15ogA1Vo0= 10 | github.com/go-text/typesetting v0.1.1-0.20240325125605-c7936fe59984/go.mod h1:2+owI/sxa73XA581LAzVuEBZ3WEEV2pXeDswCH/3i1I= 11 | github.com/go-text/typesetting-utils v0.0.0-20240317173224-1986cbe96c66 h1:GUrm65PQPlhFSKjLPGOZNPNxLCybjzjYBzjfoBGaDUY= 12 | github.com/go-text/typesetting-utils v0.0.0-20240317173224-1986cbe96c66/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= 13 | github.com/hajimehoshi/bitmapfont/v3 v3.0.0 h1:r2+6gYK38nfztS/et50gHAswb9hXgxXECYgE8Nczmi4= 14 | github.com/hajimehoshi/bitmapfont/v3 v3.0.0/go.mod h1:+CxxG+uMmgU4mI2poq944i3uZ6UYFfAkj9V6WqmuvZA= 15 | github.com/hajimehoshi/ebiten/v2 v2.7.5 h1:jN6FnhCd9NGYCsm5GtrweuikrlyVGCSUpH5YgL+7UKA= 16 | github.com/hajimehoshi/ebiten/v2 v2.7.5/go.mod h1:H2pHVgq29rfm5yeQ7jzWOM3VHsjo7/AyucODNLOhsVY= 17 | github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= 18 | github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= 19 | github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo= 20 | github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4= 21 | github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= 22 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= 23 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= 24 | golang.org/x/image v0.16.0 h1:9kloLAKhUufZhA12l5fwnx2NZW39/we1UhBesW433jw= 25 | golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs= 26 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 27 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 28 | golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 29 | golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= 30 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 31 | golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= 32 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 33 | -------------------------------------------------------------------------------- /package/game/game.go: -------------------------------------------------------------------------------- 1 | package game 2 | 3 | import ( 4 | "image/color" 5 | 6 | "github.com/gofish2020/tankgame/package/monitor" 7 | "github.com/gofish2020/tankgame/package/tank" 8 | "github.com/gofish2020/tankgame/package/utils" 9 | "github.com/gofish2020/tankgame/package/utils/sound" 10 | "github.com/hajimehoshi/ebiten/v2" 11 | ) 12 | 13 | type Game struct { 14 | tks []*tank.Tank 15 | incr int16 16 | 17 | barriers []*tank.Barrier 18 | } 19 | 20 | func NewGame() *Game { 21 | game := Game{} 22 | game.tks = append(game.tks, tank.NewTank(float64(monitor.ScreenWidth/2.0), float64(monitor.ScreenHeight-30), tank.TankTypePlayer)) 23 | return &game 24 | } 25 | 26 | func (g *Game) Restart() { 27 | if utils.GameProgress == "prepare" || utils.GameProgress == "next" { 28 | 29 | if utils.GameProgress == "prepare" { 30 | utils.KilledCount = 0 31 | utils.GameLevel = 0 32 | utils.FullMap = false 33 | } 34 | 35 | utils.GameLevel++ 36 | if utils.GameLevel > utils.MaxGameLevel { 37 | utils.GameProgress = "pass" // 通关 38 | g.barriers = nil 39 | return 40 | } 41 | // 新地图 42 | g.barriers = tank.NewMap() 43 | g.tks = nil 44 | g.tks = append(g.tks, tank.NewTank(float64(monitor.ScreenWidth/2.0), float64(monitor.ScreenHeight-30), tank.TankTypePlayer)) 45 | g.AddEnemy(2 * utils.GameLevel) 46 | utils.GameProgress = "play" 47 | 48 | } 49 | } 50 | 51 | // 新增敌人 52 | func (g *Game) AddEnemy(count int) { 53 | 54 | for range count { 55 | x, y := tank.MinXCoordinates, tank.MinYCoordinates 56 | switch g.incr % 3 { // 按照轮询的方式,选择放置位置 57 | case 0: 58 | case 1: 59 | x = float64(monitor.ScreenWidth) / 2.0 60 | case 2: 61 | x = float64(monitor.ScreenWidth) - tank.MinXCoordinates 62 | } 63 | 64 | g.tks = append(g.tks, tank.NewTank(x, y, tank.TankTypeNPC)) 65 | g.incr++ 66 | } 67 | } 68 | 69 | // 更新数据 70 | func (g *Game) Update() error { 71 | 72 | enemyCount := 0 73 | // 游戏重启 74 | g.Restart() 75 | 76 | // 播放 bgm 77 | sound.PlayBGM() 78 | 79 | // 分离 player 和 npc 坦克 80 | var playerPosition tank.TankPosition 81 | var npcPositions []tank.TankPosition 82 | 83 | // 检测存活的坦克 84 | liveTanks := []*tank.Tank{} 85 | 86 | for _, tk := range g.tks { 87 | // 更新坦克 88 | tk.Update() 89 | // 检测碰撞 90 | tk.CheckCollisions(g.tks, g.barriers) 91 | // 限制坦克运动范围 92 | tk.LimitTankRange(tank.MinXCoordinates, tank.MinYCoordinates, float64(monitor.ScreenWidth)-30, float64(monitor.ScreenHeight)-30) 93 | 94 | // 记录下坦克当前的位置 95 | if tk.TkType == tank.TankTypePlayer { 96 | playerPosition.X = tk.X 97 | playerPosition.Y = tk.Y 98 | playerPosition.TK = tk 99 | if tk.HealthPoints == 0 { 100 | tank.UpdateNameList(tk.Name) 101 | utils.GameProgress = "over" 102 | sound.PlaySound("yiwai") 103 | break 104 | } 105 | } else { 106 | // 记录npc的位置 107 | if tk.HealthPoints == 0 { 108 | tank.UpdateNameList(tk.Name) 109 | utils.KilledCount++ 110 | tk.DeathSound() 111 | } else { 112 | enemyCount++ 113 | npcPositions = append(npcPositions, tank.TankPosition{X: tk.X, Y: tk.Y, TK: tk}) 114 | } 115 | } 116 | 117 | if tk.HealthPoints != 0 { 118 | liveTanks = append(liveTanks, tk) 119 | } 120 | } 121 | 122 | // 更新 g.tks,剩余的坦克 123 | g.tks = liveTanks 124 | 125 | // 初始界面 126 | if utils.GameProgress == "init" || utils.GameProgress == "pass" { 127 | tank.MenuUpdate(g.tks) // 按钮移动 + 炮弹和按钮碰撞 128 | } else if utils.GameProgress == "play" { 129 | // 移动 npc 坦克,并检测攻击范围内敌人 130 | tank.MoveAndFineEnemyTank(playerPosition, npcPositions) 131 | } 132 | 133 | if utils.GameProgress == "play" && enemyCount == 0 { // 全部消灭 134 | utils.GameProgress = "next" // 下一关 135 | } 136 | 137 | // 游戏结束,检测按键消息 138 | tank.GameOverUpdate() 139 | return nil 140 | } 141 | 142 | func (g *Game) Draw(screen *ebiten.Image) { 143 | 144 | screen.Fill(color.RGBA{240, 222, 180, 215}) 145 | 146 | if utils.GameProgress == "init" || utils.GameProgress == "pass" { 147 | // 起始界面 148 | tank.MenuDraw(screen) 149 | } 150 | 151 | x, y := 0.0, 0.0 152 | // 绘制每个坦克 153 | for _, tk := range g.tks { 154 | tk.Draw(screen) 155 | // 绘制按键 156 | if tk.TkType == tank.TankTypePlayer { 157 | tank.KeyPressDrawAroundTank(tk, screen) 158 | x, y = tk.X, tk.Y // 以player的视角 159 | } 160 | } 161 | 162 | // 绘制战争迷雾 + 障碍物 163 | if utils.GameProgress == "play" { 164 | tank.DrawWarFogAndBarriers(screen, x, y, g.barriers) 165 | } 166 | 167 | // 绘制死亡名单 168 | tank.DrawNameList(screen) 169 | 170 | // 游戏结束界面 171 | tank.GameOverDraw(screen) 172 | } 173 | 174 | func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { 175 | return int(monitor.ScreenWidth), int(monitor.ScreenHeight) 176 | } 177 | -------------------------------------------------------------------------------- /package/tank/menu.go: -------------------------------------------------------------------------------- 1 | package tank 2 | 3 | import ( 4 | "image/color" 5 | "os" 6 | 7 | "github.com/gofish2020/tankgame/package/monitor" 8 | "github.com/gofish2020/tankgame/package/utils" 9 | "github.com/hajimehoshi/ebiten/v2" 10 | "github.com/hajimehoshi/ebiten/v2/ebitenutil" 11 | "github.com/hajimehoshi/ebiten/v2/text/v2" 12 | "github.com/hajimehoshi/ebiten/v2/vector" 13 | ) 14 | 15 | // 按钮坐标 16 | type Coordinates struct { 17 | X float64 18 | Y float64 19 | Width float64 20 | Height float64 21 | } 22 | 23 | var ( 24 | playButtonImg, exitButtonImg, logoImg *ebiten.Image 25 | 26 | playButton Coordinates 27 | exitButton Coordinates 28 | ) 29 | 30 | func init() { 31 | // 按钮图片 32 | playButtonImage, _, _ := ebitenutil.NewImageFromFile("resource/play_button.png") 33 | playButtonImg = playButtonImage 34 | 35 | exitButtonImage, _, _ := ebitenutil.NewImageFromFile("resource/exit_game_button.png") 36 | exitButtonImg = exitButtonImage 37 | 38 | logoImage, _, _ := ebitenutil.NewImageFromFile("resource/logo.png") 39 | logoImg = logoImage 40 | 41 | // 按钮坐标 42 | playButton = Coordinates{ 43 | X: monitor.ScreenWidth - 250.0, 44 | Y: 0, 45 | Width: 250, 46 | Height: 74, 47 | } 48 | 49 | exitButton = Coordinates{ 50 | X: 0, 51 | Y: monitor.ScreenHeight - 74.0, 52 | Width: 250.0, 53 | Height: 74.0, 54 | } 55 | } 56 | 57 | // **************************** 更新主菜单坐标 ********************* 58 | 59 | // 按钮移动 + 炮弹和按钮碰撞 60 | func MenuUpdate(tk []*Tank) { 61 | 62 | playButton.Y += 3 63 | if playButton.Y >= monitor.ScreenHeight { 64 | playButton.Y = 0 65 | } 66 | 67 | exitButton.X += 5 68 | if exitButton.X >= monitor.ScreenWidth { 69 | exitButton.X = 0 70 | } 71 | 72 | for _, t := range tk { 73 | for _, projectile := range t.Projectiles { 74 | 75 | if !projectile.IsExplode { 76 | if checkForCollisions(projectile.X, projectile.Y, playButton.X, playButton.Y, playButton.X+playButton.Width, playButton.Y+playButton.Height) { 77 | projectile.IsExplode = true 78 | utils.GameProgress = "prepare" 79 | } 80 | 81 | if checkForCollisions(projectile.X, projectile.Y, exitButton.X, exitButton.Y, exitButton.X+exitButton.Width, exitButton.Y+exitButton.Height) { 82 | os.Exit(0) 83 | } 84 | } 85 | } 86 | } 87 | } 88 | 89 | func checkForCollisions(x, y float64, x1, y1, x2, y2 float64) bool { 90 | 91 | return x1 < x && x < x2 && y1 < y && y < y2 92 | } 93 | 94 | // ******************* 绘制主菜单 ****************** 95 | func MenuDraw(screen *ebiten.Image) { 96 | drawButton(screen) 97 | drawTip(screen) 98 | drawLogo(screen) 99 | drawKeyborad(screen) 100 | drawDog(screen) 101 | } 102 | 103 | func drawDog(screen *ebiten.Image) { 104 | 105 | if utils.GameProgress == "pass" { 106 | dogImage, _, _ := ebitenutil.NewImageFromFile("resource/dog.jpeg") 107 | op := &ebiten.DrawImageOptions{} 108 | 109 | baseOffsetX := float64(dogImage.Bounds().Dx()) / 2 // hullBody.Bounds().Dx() = 256 110 | baseOffsetY := float64(dogImage.Bounds().Dy()) / 2 // hullBody.Bounds().Dy() = 256 111 | 112 | // 先平移图片(将图片的中心,移动到(0,0)位置) 113 | op.GeoM.Translate(-baseOffsetX, -baseOffsetY) 114 | op.GeoM.Scale(.5, .5) 115 | op.GeoM.Translate(monitor.ScreenWidth/2, monitor.ScreenHeight/2) 116 | screen.DrawImage(dogImage, op) 117 | } 118 | } 119 | 120 | func drawOneKey(x, y float32, w float32, keyWord string, screen *ebiten.Image) { 121 | 122 | defaultClr := color.RGBA{255, 215, 0, 255} 123 | pressClr := color.RGBA{255, 128, 0, 255} 124 | 125 | vector.StrokeRect(screen, x, y, w, 25, 1, color.Black, true) 126 | vector.DrawFilledRect(screen, x+1, y+1, w-2, 25-2, defaultClr, true) 127 | 128 | flag := false 129 | switch keyWord { 130 | case "W": 131 | if ebiten.IsKeyPressed(ebiten.KeyW) { 132 | flag = true 133 | } 134 | case "S": 135 | if ebiten.IsKeyPressed(ebiten.KeyS) { 136 | flag = true 137 | } 138 | case "A": 139 | if ebiten.IsKeyPressed(ebiten.KeyA) { 140 | flag = true 141 | } 142 | case "D": 143 | if ebiten.IsKeyPressed(ebiten.KeyD) { 144 | flag = true 145 | } 146 | case "J": 147 | if ebiten.IsKeyPressed(ebiten.KeyJ) { 148 | flag = true 149 | } 150 | case "K": 151 | if ebiten.IsKeyPressed(ebiten.KeyK) { 152 | flag = true 153 | } 154 | case "Space": 155 | if ebiten.IsKeyPressed(ebiten.KeySpace) { 156 | flag = true 157 | } 158 | } 159 | if flag { 160 | vector.DrawFilledRect(screen, x+1, y+1, w-2, 25-2, pressClr, true) 161 | } 162 | 163 | op := &text.DrawOptions{} 164 | op.ColorScale.ScaleWithColor(color.Black) 165 | op.GeoM.Translate(float64(x+2.0), float64(y-2.0)) 166 | 167 | text.Draw(screen, keyWord, &text.GoTextFace{ 168 | Source: mplusNormalFont, 169 | Size: 23}, op) 170 | 171 | } 172 | 173 | func drawKeyborad(screen *ebiten.Image) { 174 | 175 | drawOneKey(100.0, 400.0, 25.0, "W", screen) 176 | drawOneKey(100.0, 425.0, 25.0, "S", screen) 177 | drawOneKey(75.0, 425.0, 25.0, "A", screen) 178 | drawOneKey(125.0, 425.0, 25.0, "D", screen) 179 | 180 | op := &text.DrawOptions{} 181 | op.ColorScale.ScaleWithColor(color.Black) 182 | op.GeoM.Translate(float64(75.0), float64(450.0)) 183 | text.Draw(screen, "Move", &text.GoTextFace{ 184 | Source: mplusNormalFont, 185 | Size: 23}, op) 186 | 187 | drawOneKey(300.0, 425.0, 25.0, "J", screen) 188 | drawOneKey(325.0, 425.0, 25.0, "K", screen) 189 | op.GeoM.Reset() 190 | op.ColorScale.ScaleWithColor(color.Black) 191 | op.GeoM.Translate(float64(300.0), float64(450.0)) 192 | text.Draw(screen, "Aim", &text.GoTextFace{ 193 | Source: mplusNormalFont, 194 | Size: 23}, op) 195 | 196 | drawOneKey(175.0, 425.0, 100, "Space", screen) 197 | op.GeoM.Reset() 198 | op.ColorScale.ScaleWithColor(color.Black) 199 | op.GeoM.Translate(float64(200), float64(450.0)) 200 | text.Draw(screen, "Shoot", &text.GoTextFace{ 201 | Source: mplusNormalFont, 202 | Size: 23}, op) 203 | } 204 | 205 | func drawLogo(screen *ebiten.Image) { 206 | op := &ebiten.DrawImageOptions{} 207 | op.GeoM.Scale(.25, .25) 208 | op.GeoM.Translate(100, 150) 209 | screen.DrawImage(logoImg, op) 210 | } 211 | func drawTip(screen *ebiten.Image) { 212 | 213 | op := &text.DrawOptions{} 214 | 215 | op.ColorScale.ScaleWithColor(color.RGBA{128, 138, 135, 255}) 216 | op.GeoM.Translate(100, 50) 217 | 218 | text.Draw(screen, "github.com/gofish2020/tankgame", &text.GoTextFace{ 219 | Source: mplusNormalFont, 220 | Size: 50}, op) 221 | } 222 | func drawButton(screen *ebiten.Image) { 223 | 224 | // play button 225 | buttonOp := &ebiten.DrawImageOptions{} 226 | 227 | buttonOp.GeoM.Translate(playButton.X, playButton.Y) 228 | screen.DrawImage(playButtonImg, buttonOp) 229 | 230 | // exit button 231 | 232 | buttonOp.GeoM.Reset() 233 | 234 | buttonOp.GeoM.Translate(exitButton.X, exitButton.Y) 235 | screen.DrawImage(exitButtonImg, buttonOp) 236 | } 237 | -------------------------------------------------------------------------------- /package/tank/check.go: -------------------------------------------------------------------------------- 1 | package tank 2 | 3 | import ( 4 | "math" 5 | "math/rand" 6 | "time" 7 | 8 | "github.com/gofish2020/tankgame/package/utils" 9 | ) 10 | 11 | type Point struct { 12 | X, Y float64 13 | } 14 | 15 | func (t *Tank) CheckCollisions(tks []*Tank, barriers []*Barrier) { 16 | 17 | //子弹碰撞检测 (坦克+障碍物) 18 | t.checkProjectileCollisionWithTankOrBarriers(tks, barriers) 19 | 20 | // 坦克和障碍物碰撞检测 21 | if t.hasTankCollided(barriers) { 22 | t.moveTankToPreviousPosition() 23 | 24 | if t.TkType == TankTypeNPC { // 避免npc tank 卡住 25 | 26 | // Check if enough time has passed since the last collision 27 | if time.Since(t.LastCollisionTime) > 1*time.Second { 28 | // Randomly turn left or right 29 | if rand.Intn(2) == 0 { 30 | t.Angle += 90.0 31 | } else { 32 | t.Angle -= 90.0 33 | } 34 | 35 | // Update the last collision time 36 | t.LastCollisionTime = time.Now() 37 | } 38 | } 39 | } 40 | } 41 | 42 | func (t *Tank) moveTankToPreviousPosition() { 43 | t.X = t.PreX 44 | t.Y = t.PreY 45 | } 46 | 47 | func (t *Tank) hasTankCollided(barriers []*Barrier) bool { 48 | // 获取坦克的四个顶点 49 | tankVectors := t.getTankCollisionVectors() 50 | 51 | for _, barrier := range barriers { 52 | // 前提障碍物时可以被碰撞的(并且血量不为0) 53 | if barrier.BarrierTypeVal != BarrierTypeNone && barrier.Collidable && barrier.Health > 0 { 54 | 55 | // 获取障碍物的四个顶点 56 | objectVectors := barrier.getBarrierCollisionVectors() 57 | 58 | // 检测两个矩形是否相交 59 | if vectorsIntersect(tankVectors, objectVectors) { 60 | return true 61 | } 62 | } 63 | } 64 | 65 | // No collision detected 66 | return false 67 | } 68 | 69 | // 获取坦克的四个顶点 70 | func (t Tank) getTankCollisionVectors() []Point { 71 | // Define tank's collision points as vectors 72 | vectors := []Point{ 73 | {t.CollisionX1, t.CollisionY1}, 74 | {t.CollisionX2, t.CollisionY2}, 75 | {t.CollisionX3, t.CollisionY3}, 76 | {t.CollisionX4, t.CollisionY4}, 77 | } 78 | return vectors 79 | } 80 | 81 | // 检测矩形相交 82 | func vectorsIntersect(vectors1, vectors2 []Point) bool { 83 | 84 | // 每条边的垂直法向量 85 | for _, axis := range getAxes(vectors1) { 86 | if !projectionOverlap(axis, vectors1, vectors2) { 87 | return false 88 | } 89 | } 90 | 91 | for _, axis := range getAxes(vectors2) { 92 | if !projectionOverlap(axis, vectors1, vectors2) { 93 | return false 94 | } 95 | } 96 | 97 | return true 98 | } 99 | 100 | // 将矩形的四个顶点向量 投影到轴上并检查重叠 101 | func projectionOverlap(axis Point, vectors1, vectors2 []Point) bool { 102 | // 矩形四个顶点,在 axis投影的范围 103 | min1, max1 := projectOntoAxis(axis, vectors1) 104 | // 矩形四个顶点,在 axis投影的范围 105 | min2, max2 := projectOntoAxis(axis, vectors2) 106 | 107 | // 投影范围有重叠 108 | return (min1 <= max2 && max1 >= min2) || (min2 <= max1 && max2 >= min1) 109 | } 110 | 111 | // 计算多边形在给定轴上的投影,并返回投影的最小值和最大值。 112 | func projectOntoAxis(axis Point, vectors []Point) (float64, float64) { 113 | min, max := dotProduct(axis, vectors[0]), dotProduct(axis, vectors[0]) 114 | 115 | for _, point := range vectors[1:] { 116 | projection := dotProduct(axis, point) 117 | if projection < min { 118 | min = projection 119 | } 120 | if projection > max { 121 | max = projection 122 | } 123 | } 124 | 125 | return min, max 126 | } 127 | 128 | // 向量点积(结果是一个标量)表示v2在v1上的投影 |v2|Cos(θ) * |v1|的长度 129 | func dotProduct(v1, v2 Point) float64 { 130 | return v1.X*v2.X + v1.Y*v2.Y 131 | } 132 | 133 | // 这个 getAxes 函数计算的是多边形的法向量 134 | func getAxes(rectVectors []Point) []Point { 135 | axes := make([]Point, len(rectVectors)) 136 | 137 | for i, point := range rectVectors { 138 | nextPoint := rectVectors[(i+1)%len(rectVectors)] 139 | edgeVector := Point{X: nextPoint.X - point.X, Y: nextPoint.Y - point.Y} 140 | // 获取边的垂直向量(法向量)法向量是通过交换分量并改变一个分量的符号得到的垂直向量。 141 | axes[i] = Point{X: -edgeVector.Y, Y: edgeVector.X} 142 | } 143 | return axes 144 | } 145 | 146 | // ////////////////// 子弹 和 (坦克+障碍物)碰撞检测 ////////////////// 147 | func (t *Tank) checkProjectileCollisionWithTankOrBarriers(tks []*Tank, barriers []*Barrier) { 148 | for _, projectile := range t.Projectiles { 149 | for _, tk := range tks { 150 | 151 | if !projectile.IsExplode { // 坦克 152 | if isProjectileCollisionsTank(projectile.X, projectile.Y, tk, t) { 153 | projectile.IsExplode = true 154 | return 155 | } 156 | } 157 | } 158 | 159 | for _, barrier := range barriers { 160 | if !projectile.IsExplode { // 障碍物 161 | if isProjectileCollisionsBarrier(projectile.X, projectile.Y, barrier) { 162 | projectile.IsExplode = true 163 | return 164 | } 165 | } 166 | } 167 | } 168 | } 169 | 170 | func isProjectileCollisionsBarrier(x, y float64, barrier *Barrier) bool { 171 | 172 | if barrier.BarrierTypeVal != BarrierTypeNone && barrier.Destructible && barrier.Health > 0 { 173 | 174 | // 障碍物的范围 175 | left := barrier.X 176 | right := (barrier.X + barrier.Width) 177 | top := barrier.Y 178 | bottom := (barrier.Y + barrier.Height) 179 | 180 | // 在障碍物内 181 | if x >= left && x <= right && y >= top && y <= bottom { 182 | 183 | if barrier.BarrierTypeVal == BarrierTypeBrick { // 表示砖块 184 | 185 | dx := math.Min(right-x, x-left) // 距离左右的最短距离 186 | dy := math.Min(bottom-y, y-top) // 距离上下的最短距离 187 | 188 | if dx < dy { // 说明距离左右的最小值 比距离上下的最小值【更小】 189 | if x-left < right-x { // 更靠近left 190 | changeBarrier(barrier, "l") 191 | } else { // 更靠近 right 192 | changeBarrier(barrier, "r") 193 | } 194 | } else { 195 | if y-top < bottom-y { // top 196 | changeBarrier(barrier, "t") 197 | } else { // bottom 198 | changeBarrier(barrier, "b") 199 | } 200 | } 201 | } else if barrier.BarrierTypeVal == BarrierTypeBug { 202 | barrier.Health = 0 203 | utils.FullMap = !utils.FullMap 204 | } 205 | return true 206 | } 207 | } 208 | return false 209 | } 210 | 211 | func isProjectileCollisionsTank(x, y float64, t *Tank, origin *Tank) bool { 212 | 213 | if t == origin { 214 | return false 215 | } 216 | 217 | // 利用差积判断是否在矩形内部 218 | // vertices := []Point{{t.CollisionX1, t.CollisionY1}, {t.CollisionX2, t.CollisionY2}, {t.CollisionX3, t.CollisionY3}, {t.CollisionX4, t.CollisionY4}} 219 | // if checkCollision1(Point{x, y}, vertices) { 220 | // t.HealthPoints -= 50 // 扣除血条 221 | // return true 222 | // } 223 | 224 | if checkCollision(Point{x, y}, t.X, t.Y, t.Width, t.Height, t.Angle) { 225 | t.HealthPoints -= 50 // 扣除血条 226 | return true 227 | } 228 | return false 229 | } 230 | 231 | func checkCollision(point Point, cx, cy float64, width, height float64, tankAngle float64) bool { 232 | 233 | // 坦克旋转 tankAngle角度,等价于 坦克不旋转,点 point 逆向旋转 -tankAngle 234 | rotatedPX, rotatedPY := rotatePoint(point.X, point.Y, -tankAngle, cx, cy) 235 | 236 | halfW, halfH := width/2, height/2 237 | 238 | xTop, yTop := cx-halfW, cy-halfH 239 | xBottom, yBottom := cx+halfW, cy+halfH 240 | 241 | // 就把旋转矩形变成不旋转的状态 242 | if xTop <= rotatedPX && rotatedPX <= xBottom && yTop <= rotatedPY && rotatedPY <= yBottom { 243 | return true 244 | } 245 | return false 246 | } 247 | 248 | // 这里的 vertices 的坐标点,是逆时针的四个点 249 | func checkCollision1(point Point, vertices []Point) bool { 250 | 251 | // 使用交叉乘积法判断点是否在多边形内 252 | for i := 0; i < 4; i++ { 253 | next := (i + 1) % 4 254 | // 因为四条边的方向是逆时针,来进行向量计算,所以 point 也需要在处于逆时针的方向 255 | if isOut(vertices[i], vertices[next], point) { 256 | return false 257 | } 258 | } 259 | // 如果point 都在四条边的左边,说明point在 vertices 内部 260 | return true 261 | } 262 | 263 | // 叉积公式 u * v = (x1,y1) * (x2,y2) = x1*y2 - y1*x2 如果大于0,表示 v 在u的右侧(顺时针方向) 264 | func isOut(p1, p2, p Point) bool { 265 | 266 | // 从 p1 到p2 的向量 * 从 p1 到 p的向量 267 | return ((p2.X-p1.X)*(p.Y-p1.Y) - (p2.Y-p1.Y)*(p.X-p1.X)) > 0 268 | } 269 | -------------------------------------------------------------------------------- /package/tank/barrier.go: -------------------------------------------------------------------------------- 1 | package tank 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | "math" 7 | "sort" 8 | 9 | "github.com/gofish2020/tankgame/package/monitor" 10 | "github.com/gofish2020/tankgame/package/utils" 11 | "github.com/hajimehoshi/ebiten/v2" 12 | "github.com/hajimehoshi/ebiten/v2/ebitenutil" 13 | ) 14 | 15 | type line struct { 16 | X1, Y1, X2, Y2 float64 17 | } 18 | 19 | func (l *line) angle() float64 { 20 | return math.Atan2(l.Y2-l.Y1, l.X2-l.X1) 21 | } 22 | 23 | type object struct { 24 | Walls []line 25 | } 26 | 27 | func (o object) points() [][2]float64 { 28 | 29 | var points [][2]float64 30 | for _, wall := range o.Walls { // 每个线的 X2,Y2 31 | points = append(points, [2]float64{wall.X2, wall.Y2}) 32 | } 33 | 34 | // 最后闭合最后一点 35 | p := [2]float64{o.Walls[0].X1, o.Walls[0].Y1} 36 | // 避免重复 37 | if p[0] != points[len(points)-1][0] && p[1] != points[len(points)-1][1] { 38 | points = append(points, p) 39 | } 40 | return points 41 | } 42 | 43 | // 矩形的四个边 44 | func rect(x, y, w, h float64) []line { 45 | 46 | // 逆时针 47 | return []line{ 48 | {x, y, x, y + h}, 49 | {x, y + h, x + w, y + h}, 50 | {x + w, y + h, x + w, y}, 51 | {x + w, y, x, y}, 52 | } 53 | } 54 | 55 | type Image struct { 56 | X int 57 | Y int 58 | Width int 59 | Height int 60 | Path string 61 | } 62 | 63 | type BarrierType string 64 | 65 | var ( 66 | BarrierTypeNone BarrierType = "None" 67 | BarrierTypeBrick BarrierType = "Brick" 68 | BarrierTypeWater BarrierType = "Water" 69 | BarrierTypeIron BarrierType = "Iron" 70 | BarrierTypeCamo BarrierType = "Camo" 71 | BarrierTypeLeaves BarrierType = "Leaves" 72 | BarrierTypeBug BarrierType = "Bug" 73 | ) 74 | 75 | type Barrier struct { 76 | 77 | // 障碍物范围(位置 + 大小) 78 | X float64 79 | Y float64 80 | Width float64 81 | Height float64 82 | 83 | // 障碍物的图片 84 | Image Image 85 | 86 | // 用来做 阴影计算 87 | Objects []object 88 | 89 | Destructible bool // 是否可破坏 90 | Collidable bool // 是否可碰撞 91 | 92 | Health int 93 | 94 | BarrierTypeVal BarrierType 95 | } 96 | 97 | func (b Barrier) getBarrierCollisionVectors() []Point { 98 | // 顺时针的四个顶点 99 | vectors := []Point{ 100 | {b.X, b.Y}, 101 | {b.X + b.Width, b.Y}, 102 | {b.X + b.Width, b.Y + b.Height}, 103 | {b.X, b.Y + b.Height}, 104 | } 105 | return vectors 106 | } 107 | 108 | func addBoard(x, y float64) *Barrier { 109 | 110 | return &Barrier{ 111 | X: x, 112 | Y: y, 113 | Width: monitor.ScreenWidth, 114 | Height: monitor.ScreenHeight, 115 | Health: 100, 116 | BarrierTypeVal: BarrierTypeNone, 117 | 118 | Objects: []object{{rect(0, 0, monitor.ScreenWidth, monitor.ScreenHeight)}}, 119 | } 120 | } 121 | 122 | func addBarrier(x, y float64, BarrierTypeVal BarrierType) *Barrier { 123 | b := Barrier{ 124 | X: x, 125 | Y: y, 126 | Width: 64, 127 | Height: 64, 128 | Health: 100, 129 | 130 | // 默认砖块(为了做裁剪) 131 | Image: Image{ 132 | X: 0, 133 | Y: 0, 134 | Height: 64, 135 | Width: 64, 136 | }, 137 | 138 | // 为了阴影计算 139 | Objects: []object{{rect(x, y, 64, 64)}}, 140 | 141 | BarrierTypeVal: BarrierTypeVal, 142 | } 143 | 144 | if BarrierTypeVal == BarrierTypeBrick { // 砖块 145 | b.Image.Path = "resource/Brick_Block_small.png" 146 | // 不可穿越,可毁坏 147 | b.Destructible = true 148 | b.Collidable = true 149 | 150 | } else if BarrierTypeVal == BarrierTypeCamo { // 沙漠 151 | b.Image.Path = "resource/camo_net.png" 152 | b.Objects = []object{{Walls: rect(float64(x), float64(y), 0.0, 0.0)}} // 说明不用做阴影计算 153 | 154 | // 可穿越,不可毁坏 155 | b.Destructible = false 156 | b.Collidable = false 157 | 158 | } else if BarrierTypeVal == BarrierTypeWater { // 水 159 | b.Image.Path = "resource/water.png" 160 | // 说明不用做阴影计算 161 | b.Objects = []object{{Walls: rect(float64(x), float64(y), 0.0, 0.0)}} // 说明不用做阴影计算 162 | 163 | // 不可穿越,不可毁坏 164 | b.Destructible = false 165 | b.Collidable = true 166 | } else if BarrierTypeVal == BarrierTypeLeaves { // 草地 167 | b.Image.Path = "resource/leaves.png" 168 | 169 | // 可穿越 不可毁坏 170 | b.Destructible = false 171 | b.Collidable = false 172 | } else if BarrierTypeVal == BarrierTypeIron { // 铁块 173 | b.Image.Path = "resource/iron.png" 174 | // 不可穿越 可毁坏 175 | b.Destructible = true 176 | b.Collidable = true 177 | } else if BarrierTypeVal == BarrierTypeBug { 178 | 179 | b.Image.Path = "resource/bug.png" 180 | // 不可穿越 可毁坏 181 | b.Destructible = true 182 | b.Collidable = true 183 | b.Objects = []object{{Walls: rect(float64(x), float64(y), 0.0, 0.0)}} // 说明不用做阴影计算 184 | } 185 | 186 | return &b 187 | } 188 | 189 | // 创建地图 190 | 191 | const padding = 150 192 | 193 | func NewMap() []*Barrier { 194 | 195 | freq := 0 196 | switch utils.GameLevel { 197 | case 1: 198 | freq = 30 199 | case 2: 200 | freq = 24 201 | case 3: 202 | freq = 18 203 | case 4: 204 | freq = 12 205 | default: 206 | freq = 10 207 | } 208 | 209 | return createMap(padding, padding, monitor.ScreenWidth-padding, monitor.ScreenHeight-padding, freq) 210 | } 211 | 212 | // x1, y1, x2, y2 绘制障碍物的范围 freq 障碍物出现的可能性 213 | func createMap(x1, y1, x2, y2 float64, freq int) []*Barrier { 214 | 215 | bugCount := 1 216 | var barriers []*Barrier 217 | // 初始边界 218 | barriers = append(barriers, addBoard(0, 0)) 219 | 220 | for x := x1; x < x2; x = x + 64. { 221 | for y := y1; y < y2; y = y + 64. { 222 | 223 | typeVal := BarrierTypeNone 224 | switch r.Intn(freq) { 225 | case 0: 226 | typeVal = BarrierTypeBrick 227 | case 1: 228 | typeVal = BarrierTypeCamo 229 | case 2: 230 | typeVal = BarrierTypeWater 231 | case 3: 232 | typeVal = BarrierTypeLeaves 233 | case 4: 234 | typeVal = BarrierTypeIron 235 | case 5: 236 | typeVal = BarrierTypeBug 237 | } 238 | 239 | if typeVal != BarrierTypeNone { 240 | if typeVal == BarrierTypeBug { 241 | if bugCount > 0 { 242 | bugCount-- 243 | } else { 244 | typeVal = BarrierTypeBrick 245 | } 246 | } 247 | barriers = append(barriers, addBarrier(x, y, typeVal)) 248 | } 249 | } 250 | } 251 | return barriers 252 | } 253 | 254 | func changeBarrier(barrier *Barrier, side string) { 255 | 256 | switch side { 257 | case "l": 258 | 259 | // 障碍物范围 260 | barrier.X += 32 261 | barrier.Width -= 32 262 | 263 | // 图片裁剪 264 | barrier.Image.X += 32 265 | 266 | // 墙体的边界 (逆时针的四条边) 267 | barrier.Objects[0].Walls[0].X1 += 32 268 | barrier.Objects[0].Walls[0].X2 += 32 269 | barrier.Objects[0].Walls[1].X1 += 32 270 | barrier.Objects[0].Walls[3].X2 += 32 271 | case "r": 272 | barrier.Width -= 32 273 | 274 | barrier.Image.X = 0 275 | barrier.Image.Width -= 32 276 | 277 | barrier.Objects[0].Walls[1].X2 -= 32 278 | barrier.Objects[0].Walls[2].X1 -= 32 279 | barrier.Objects[0].Walls[2].X2 -= 32 280 | barrier.Objects[0].Walls[3].X1 -= 32 281 | case "t": 282 | barrier.Y += 32 283 | barrier.Height -= 32 284 | 285 | barrier.Image.Y += 32 286 | 287 | barrier.Objects[0].Walls[0].Y1 += 32 288 | barrier.Objects[0].Walls[2].Y2 += 32 289 | barrier.Objects[0].Walls[3].Y1 += 32 290 | barrier.Objects[0].Walls[3].Y2 += 32 291 | case "b": 292 | barrier.Height -= 32 293 | 294 | barrier.Image.Height -= 32 295 | 296 | barrier.Objects[0].Walls[0].Y2 -= 32 297 | barrier.Objects[0].Walls[1].Y1 -= 32 298 | barrier.Objects[0].Walls[1].Y2 -= 32 299 | barrier.Objects[0].Walls[2].Y1 -= 32 300 | } 301 | 302 | // 当障碍物的宽/高为0,表示障碍物已经清理 303 | if barrier.Height == 0 || barrier.Width == 0 { 304 | barrier.Health = 0 305 | } 306 | } 307 | 308 | ////////////////////////// 光源照射 (阴影计算)//////////////////////// 309 | 310 | var ( 311 | // 阴影 312 | shadowImage = ebiten.NewImage(int(monitor.ScreenWidth), int(monitor.ScreenHeight)) 313 | triangleImage = ebiten.NewImage(int(monitor.ScreenWidth), int(monitor.ScreenHeight)) 314 | ) 315 | 316 | func init() { 317 | triangleImage.Fill(color.White) 318 | } 319 | 320 | func DrawWarFogAndBarriers(screen *ebiten.Image, x, y float64, barriers []*Barrier) { 321 | 322 | if !utils.FullMap { 323 | drawFog(screen, x, y, barriers) 324 | } 325 | // 绘制障碍物 326 | drawBarrier(screen, x, y, barriers) 327 | 328 | } 329 | 330 | func drawBarrier(screen *ebiten.Image, x, y float64, barriers []*Barrier) { 331 | // 绘制障碍物 332 | for _, barrier := range barriers { 333 | if barrier.BarrierTypeVal == BarrierTypeNone || barrier.Health == 0 { 334 | continue 335 | } 336 | originalImg, _, _ := ebitenutil.NewImageFromFile(barrier.Image.Path) 337 | // 对图片 originalImg 进行裁剪 338 | subImg := originalImg.SubImage(image.Rect(barrier.Image.X, barrier.Image.Y, 339 | barrier.Image.Width, barrier.Image.Height)).(*ebiten.Image) 340 | // 绘制裁剪后的图片 341 | options := &ebiten.DrawImageOptions{} 342 | options.GeoM.Translate(barrier.X, barrier.Y) 343 | screen.DrawImage(subImg, options) 344 | } 345 | } 346 | func drawFog(screen *ebiten.Image, x, y float64, barriers []*Barrier) { 347 | shadowImage.Fill(color.Black) 348 | 349 | // x,y 相当于光源的位置 350 | rays := rayCasting(float64(x), float64(y), barriers) 351 | 352 | opt := &ebiten.DrawTrianglesOptions{} 353 | opt.Address = ebiten.AddressRepeat 354 | opt.Blend = ebiten.BlendSourceOut 355 | for i, line := range rays { 356 | nextLine := rays[(i+1)%len(rays)] 357 | // 用三个点构成一个三角形 358 | v := rayVertices(float64(x), float64(y), nextLine.X2, nextLine.Y2, line.X2, line.Y2) 359 | // 裁剪为白色 360 | shadowImage.DrawTriangles(v, []uint16{0, 1, 2}, triangleImage, opt) 361 | } 362 | 363 | // 绘制迷雾最终效果 364 | op := &ebiten.DrawImageOptions{} 365 | op.ColorScale.ScaleAlpha(1.0) 366 | screen.DrawImage(shadowImage, op) 367 | } 368 | 369 | // intersection 计算给定的两条之间的交点 370 | func intersection(l1, l2 line) (float64, float64, bool) { 371 | 372 | // https://en.wikipedia.org/wiki/Line%E2%80%93line_intersection#Given_two_points_on_each_line 373 | denom := (l1.X1-l1.X2)*(l2.Y1-l2.Y2) - (l1.Y1-l1.Y2)*(l2.X1-l2.X2) 374 | tNum := (l1.X1-l2.X1)*(l2.Y1-l2.Y2) - (l1.Y1-l2.Y1)*(l2.X1-l2.X2) 375 | uNum := -((l1.X1-l1.X2)*(l1.Y1-l2.Y1) - (l1.Y1-l1.Y2)*(l1.X1-l2.X1)) 376 | 377 | if denom == 0 { 378 | return 0, 0, false 379 | } 380 | 381 | t := tNum / denom 382 | if t > 1 || t < 0 { 383 | return 0, 0, false 384 | } 385 | 386 | u := uNum / denom 387 | if u > 1 || u < 0 { 388 | return 0, 0, false 389 | } 390 | 391 | x := l1.X1 + t*(l1.X2-l1.X1) 392 | y := l1.Y1 + t*(l1.Y2-l1.Y1) 393 | return x, y, true 394 | } 395 | 396 | func newRay(x, y, length, angle float64) line { 397 | return line{ 398 | X1: x, 399 | Y1: y, 400 | X2: x + length*math.Cos(angle), 401 | Y2: y + length*math.Sin(angle), 402 | } 403 | } 404 | 405 | // rayCasting 返回从点 cx, cy 出发并与对象相交的直线切片 406 | func rayCasting(cx, cy float64, barriers []*Barrier) []line { 407 | const rayLength = 10000 // something large enough to reach all objects 408 | 409 | var rays []line 410 | 411 | for _, bar := range barriers { 412 | 413 | if bar.Health > 0 { // 障碍物有血 414 | 415 | for _, obj := range bar.Objects { 416 | // 遍历每个对象中【点集合】 417 | for _, p := range obj.points() { 418 | // cx/cy 和 p[0],p[1] 构成一个线段 419 | l := line{cx, cy, p[0], p[1]} 420 | // 从 cx/cy 出发到 p[0]/p[1] 构成的线段和 x轴正方向的夹角 421 | angle := l.angle() 422 | 423 | for _, offset := range []float64{-0.005, 0.005} { 424 | points := [][2]float64{} 425 | 426 | // 从点 cx,cy 发出一束光,长度为rayLength,角度为 angle +/- 0.005 427 | ray := newRay(cx, cy, rayLength, angle+offset) 428 | 429 | // 将光线ray 和 所有对象的所有的边,求交点 430 | for _, bar := range barriers { // 所有的对象 431 | 432 | if bar.Health > 0 { // 障碍物有血 433 | 434 | for _, o := range bar.Objects { 435 | for _, wall := range o.Walls { 436 | if px, py, ok := intersection(ray, wall); ok { // 判断两个线段是否有交点 437 | points = append(points, [2]float64{px, py}) // 记录交点 438 | } 439 | } 440 | } 441 | } 442 | } 443 | 444 | // 只保留 和 cx/cy 距离最近的交点 445 | min := math.Inf(1) // 正无穷 446 | minI := -1 447 | for i, p := range points { 448 | d2 := (cx-p[0])*(cx-p[0]) + (cy-p[1])*(cy-p[1]) // 点 cx/cy 和 p[0]/p[1] 之间的距离的平方(勾股定理) 449 | if d2 < min { 450 | min = d2 451 | minI = i 452 | } 453 | } 454 | 455 | if minI != -1 { 456 | // 记录距离 cx/cy 和 最近的点,组成的线段 457 | rays = append(rays, line{cx, cy, points[minI][0], points[minI][1]}) 458 | } 459 | } 460 | } 461 | } 462 | } 463 | 464 | } 465 | 466 | // Sort rays based on angle, otherwise light triangles will not come out right 467 | sort.Slice(rays, func(i int, j int) bool { 468 | return rays[i].angle() < rays[j].angle() 469 | }) 470 | return rays 471 | } 472 | 473 | func rayVertices(x1, y1, x2, y2, x3, y3 float64) []ebiten.Vertex { 474 | return []ebiten.Vertex{ 475 | {DstX: float32(x1), DstY: float32(y1), SrcX: 0, SrcY: 0, ColorR: 1, ColorG: 1, ColorB: 1, ColorA: 1}, 476 | {DstX: float32(x2), DstY: float32(y2), SrcX: 0, SrcY: 0, ColorR: 1, ColorG: 1, ColorB: 1, ColorA: 1}, 477 | {DstX: float32(x3), DstY: float32(y3), SrcX: 0, SrcY: 0, ColorR: 1, ColorG: 1, ColorB: 1, ColorA: 1}, 478 | } 479 | } 480 | -------------------------------------------------------------------------------- /package/tank/tank.go: -------------------------------------------------------------------------------- 1 | package tank 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | "math" 7 | "math/rand" 8 | "strconv" 9 | "time" 10 | 11 | "github.com/gofish2020/tankgame/package/monitor" 12 | "github.com/gofish2020/tankgame/package/utils" 13 | "github.com/gofish2020/tankgame/package/utils/sound" 14 | "github.com/hajimehoshi/ebiten/v2" 15 | "github.com/hajimehoshi/ebiten/v2/ebitenutil" 16 | ) 17 | 18 | type TankType int 19 | 20 | const ( 21 | ScreenToLogicScaleX = 5.12 // 图片是 256大小,希望缩为 50 22 | ScreenToLogicScaleY = 5.12 23 | 24 | MinXCoordinates = 30.0 25 | MinYCoordinates = 30.0 26 | 27 | TankTypePlayer TankType = iota 28 | TankTypeNPC 29 | ) 30 | 31 | type Tank struct { 32 | X float64 33 | Y float64 34 | Width float64 // 宽度 35 | Height float64 // 高度 36 | 37 | // 记录前一个位置,当做碰撞检测时候,回撤到前一个位置 38 | PreX float64 39 | PreY float64 40 | 41 | Name string 42 | 43 | TkType TankType // 坦克的操作者 44 | ImagePath string // 坦克图片 45 | 46 | // 🩸血量 47 | HealthPoints int 48 | MaxHealthPoints int 49 | HealthBarWidth float64 50 | HealthBarHeight float64 51 | 52 | // 炮弹装填 53 | ReloadTimer int 54 | ReloadMaxTimer int 55 | ReloadSpeed int 56 | ReloadBarWidth float64 57 | ReloadBarHeight float64 58 | 59 | // 旋转角度 60 | Angle float64 61 | // 角度变化速率 62 | RotationSpeed float64 63 | 64 | //前进速度 65 | ForwardSpeed float64 66 | // 后退速度 67 | BackwardSpeed float64 68 | 69 | // 四个角,旋转后的坐标(做碰撞检测) 70 | // 顺时针,左上 71 | CollisionX1 float64 72 | CollisionY1 float64 73 | // 右上 74 | CollisionX2 float64 75 | CollisionY2 float64 76 | // 右下 77 | CollisionX3 float64 78 | CollisionY3 float64 79 | // 左下 80 | CollisionX4 float64 81 | CollisionY4 float64 82 | 83 | // 炮塔参数 84 | Turrent Turret 85 | 86 | // 在攻击范围内的坦克 87 | Enemy *Tank 88 | 89 | Projectiles []*Projectile // 发射的炮弹 90 | 91 | LastCollisionTime time.Time // 碰撞发生的时间 92 | } 93 | 94 | // 炮弹 95 | type Projectile struct { 96 | X float64 // 炮弹坐标 X 97 | Y float64 // 炮弹坐标 Y 98 | Speed float64 // 运行速率 99 | Angle float64 // 移动方向 100 | Width float64 // 宽度 101 | Height float64 // 高度 102 | IsExplode bool // 是否已碰撞 103 | 104 | Frame int // 爆炸图片裁剪 105 | } 106 | 107 | // 炮塔 108 | type Turret struct { 109 | Angle float64 110 | ImagePath string 111 | 112 | // 炮塔旋转速度 113 | RotationSpeed float64 114 | 115 | //攻击范围 116 | RangeAngle float64 117 | RangeDistance float64 118 | 119 | //子弹速率 120 | ProjectileSpeed float64 121 | } 122 | 123 | var ( 124 | r *rand.Rand 125 | ) 126 | 127 | func init() { 128 | r = rand.New(rand.NewSource(time.Now().Unix())) 129 | } 130 | 131 | func NewTank(x, y float64, tankType TankType) *Tank { 132 | 133 | tank := Tank{ 134 | 135 | X: x, 136 | Y: y, 137 | ImagePath: "resource/green_tank.png", 138 | 139 | Width: 50, // 坦克的宽 140 | Height: 50, // 坦克的高 141 | 142 | TkType: tankType, 143 | Angle: 270.0, 144 | RotationSpeed: 2.0, 145 | 146 | ForwardSpeed: 5.0, 147 | BackwardSpeed: 3.5, 148 | 149 | ReloadTimer: 0, 150 | ReloadMaxTimer: 100, 151 | ReloadSpeed: 1.0, 152 | 153 | ReloadBarWidth: 50, 154 | ReloadBarHeight: 5, 155 | 156 | HealthPoints: 200, 157 | MaxHealthPoints: 200, 158 | HealthBarWidth: 50, 159 | HealthBarHeight: 5, 160 | 161 | Turrent: Turret{ 162 | Angle: 270.0, // 默认指向上 163 | ImagePath: "resource/green_tank_turret.png", 164 | RotationSpeed: 2.0, 165 | ProjectileSpeed: 30.0, 166 | }, 167 | 168 | Projectiles: nil, 169 | Enemy: nil, 170 | } 171 | 172 | if tankType == TankTypePlayer { 173 | tank.Turrent.RangeAngle = 360.0 174 | tank.Turrent.RangeDistance = 300.0 175 | tank.Name = "ikun" 176 | tank.ReloadSpeed = 2.0 177 | } else { 178 | 179 | var level utils.TankLevel // 随机坦克的速度 180 | if utils.GameLevel <= 3 { 181 | level = utils.TankLevels[r.Intn(utils.GameLevel*6)] 182 | } else { 183 | level = utils.TankLevels[r.Intn(len(utils.TankLevels))] 184 | } 185 | 186 | tank.ImagePath = "resource/brown_tank.png" 187 | tank.MaxHealthPoints = 50 188 | tank.HealthPoints = 50 189 | tank.Angle = 90.0 190 | tank.ForwardSpeed = level.TankSpeed // 前进速度 191 | tank.Turrent.RotationSpeed = level.TurrentRotateSpeed // 炮塔旋转速度 192 | 193 | tank.Turrent.RangeAngle = 45.0 // 攻击视角 194 | tank.Turrent.RangeDistance = 100.0 + float64(r.Intn(300)) // 攻击范围 195 | tank.Turrent.ImagePath = "resource/brown_tank_turret.png" 196 | tank.Turrent.Angle = 90.0 // 敌人默认指向下 197 | tank.Name = enemyNames[r.Intn(len(enemyNames))] 198 | } 199 | // 更新坦克的四个顶点坐标 200 | tank.updateTankCollisionBox() 201 | return &tank 202 | } 203 | 204 | func (t *Tank) DeathSound() { 205 | 206 | soundName := strconv.Itoa(utils.KilledCount) 207 | if utils.KilledCount > 5 { 208 | soundName = "dead" + strconv.Itoa(rand.Intn(4)+1) 209 | } 210 | sound.PlaySound(soundName) 211 | } 212 | func (t *Tank) shot() { 213 | // 能量满,才能射击 214 | if t.ReloadTimer == t.ReloadMaxTimer { 215 | if t.TkType == TankTypePlayer { // player 216 | if utils.GameProgress == "pass" { 217 | sound.PlaySound("dog") 218 | } else { 219 | sound.PlaySound("boom") 220 | } 221 | } 222 | 223 | t.ReloadTimer = 0 224 | // 生成炮弹 225 | newProjectile := Projectile{ 226 | X: t.X, // 炮弹初始X 227 | Y: t.Y, // 炮弹初始Y 228 | Angle: t.Turrent.Angle, // 初始角度(就是炮塔的角度) 229 | IsExplode: false, // 是否已经爆炸 230 | Speed: t.Turrent.ProjectileSpeed, // 炮弹移动速度 231 | } 232 | t.Projectiles = append(t.Projectiles, &newProjectile) 233 | } 234 | 235 | } 236 | 237 | // 目的在于让 炮塔的角度始终使用 正度数 表示 [0,360]之间 238 | func (t *Tank) AddTurrentAngle(duration float64) { 239 | 240 | t.Turrent.Angle += duration 241 | if t.Turrent.Angle >= 360.0 { // 超过360,转成360度范围 242 | t.Turrent.Angle -= 360.0 243 | } else if t.Turrent.Angle < 0 { // 负数转正数 244 | t.Turrent.Angle += 360.0 245 | } 246 | } 247 | 248 | // 目的在于让 坦克的角度始终使用 正度数 表示 [0,360]之间 249 | func (t *Tank) AddTankAngle(duration float64) { 250 | 251 | t.Angle += duration 252 | if t.Angle >= 360.0 { // 超过360,转成360度范围 253 | t.Angle -= 360.0 254 | } else if t.Angle < 0 { // 负数转正数 255 | t.Angle += 360.0 256 | } 257 | } 258 | 259 | func (t *Tank) Update() { 260 | 261 | // 填充子弹 262 | if t.ReloadTimer < t.ReloadMaxTimer { 263 | t.ReloadTimer += t.ReloadSpeed 264 | if t.ReloadTimer > t.ReloadMaxTimer { 265 | t.ReloadTimer = t.ReloadMaxTimer 266 | } 267 | } 268 | 269 | if t.TkType == TankTypePlayer { // 玩家坦克,手瞄 270 | 271 | if ebiten.IsKeyPressed(ebiten.KeySpace) { 272 | t.shot() 273 | } 274 | if ebiten.IsKeyPressed(ebiten.KeyA) { // Press A 275 | t.AddTankAngle(-t.RotationSpeed) 276 | t.updateTankCollisionBox() 277 | } else if ebiten.IsKeyPressed(ebiten.KeyD) { // Press D 278 | 279 | t.AddTankAngle(t.RotationSpeed) 280 | t.updateTankCollisionBox() 281 | } 282 | if ebiten.IsKeyPressed(ebiten.KeyW) { // Press W 283 | t.PreX, t.PreY = t.X, t.Y // 记录前一个位置,当做碰撞检测时候,来回撤到前一个位置 284 | 285 | t.X += t.ForwardSpeed * math.Cos(t.Angle*math.Pi/180) 286 | t.Y += t.ForwardSpeed * math.Sin(t.Angle*math.Pi/180) 287 | t.updateTankCollisionBox() 288 | } else if ebiten.IsKeyPressed(ebiten.KeyS) { // Press S 289 | 290 | t.PreX, t.PreY = t.X, t.Y 291 | 292 | t.Y -= t.BackwardSpeed * math.Sin(t.Angle*math.Pi/180) 293 | t.X -= t.BackwardSpeed * math.Cos(t.Angle*math.Pi/180) 294 | t.updateTankCollisionBox() 295 | } 296 | 297 | // 手动瞄准 298 | if ebiten.IsKeyPressed(ebiten.KeyJ) { // Press J 299 | t.AddTurrentAngle(-t.Turrent.RotationSpeed) 300 | } else if ebiten.IsKeyPressed(ebiten.KeyK) { // Press K 301 | t.AddTurrentAngle(t.Turrent.RotationSpeed) 302 | } 303 | 304 | } else { // npc tank 自瞄 305 | 306 | enemy := t.Enemy 307 | if enemy != nil { // 有敌人,自动瞄准 308 | 309 | x1, y1 := enemy.X, enemy.Y 310 | x2, y2 := t.X, t.Y 311 | 312 | // 计算夹角 313 | angle := float64(int(math.Atan2(y1-y2, x1-x2) / math.Pi * 180)) 314 | // 角度限定在 [0,360] 315 | if angle < 0 { 316 | angle += 360 317 | } 318 | 319 | // t.Turrent.Angle 表示炮塔和 x轴的夹角 320 | // angle 表示两个坦克连线 和 x轴的夹角 321 | if t.Turrent.Angle > angle { 322 | // 目的让t.Turrent.Angle 往夹角小的方向移动,让炮台尽可能快的对准敌人 323 | if t.Turrent.Angle-angle > 180 { 324 | t.AddTurrentAngle(1) 325 | } else { 326 | t.AddTurrentAngle(-1) 327 | } 328 | } else if t.Turrent.Angle < angle { 329 | 330 | if angle-t.Turrent.Angle > 180 { 331 | t.AddTurrentAngle(-1) 332 | } else { 333 | t.AddTurrentAngle(1) 334 | } 335 | } else { 336 | // 这里精准瞄准,立刻射击 337 | t.shot() 338 | } 339 | 340 | //t.shot() // 不管是否瞄准,就射击 341 | } 342 | } 343 | 344 | // 更新炮弹的移动 345 | t.updateProjectile() 346 | 347 | } 348 | 349 | // 更新坦克的四个顶点边界 350 | func (t *Tank) updateTankCollisionBox() { 351 | 352 | // 用来作为坦克四个角的初始坐标 353 | offsetX := float64(t.Width) / 2 354 | offsetY := float64(t.Height) / 2 355 | 356 | // t.X t.Y 矩形的中心点 357 | // 左上角 (x = -offsetX y = -offsetY) 358 | t.CollisionX1, t.CollisionY1 = rotatePoint(t.X-offsetX, t.Y-offsetY, t.Angle, t.X, t.Y) 359 | // 右上角 (x = offsetX y = -offsetY ) 360 | t.CollisionX2, t.CollisionY2 = rotatePoint(t.X+offsetX, t.Y-offsetY, t.Angle, t.X, t.Y) 361 | // 右下角 (x = offsetX y = offsetY) 362 | t.CollisionX3, t.CollisionY3 = rotatePoint(t.X+offsetX, t.Y+offsetY, t.Angle, t.X, t.Y) 363 | // 左下角 (x = -offsetX y=offsetY) 364 | t.CollisionX4, t.CollisionY4 = rotatePoint(t.X-offsetX, t.Y+offsetY, t.Angle, t.X, t.Y) 365 | 366 | } 367 | 368 | // 点 x/y 围绕点 cx/cy 旋转 angle 角度后的坐标 369 | func rotatePoint(x, y, angle, cx, cy float64) (float64, float64) { 370 | 371 | // 角度转弧度 372 | angleRad := angle * math.Pi / 180 373 | cosAngle := math.Cos(angleRad) 374 | sinAngle := math.Sin(angleRad) 375 | 376 | // 表示让 x/y 以 cx/cy 作为原点的坐标 377 | x -= cx 378 | y -= cy 379 | 380 | /* 381 | 矩阵旋转公式: 382 | x' = xCos(θ) - ySin(θ) 383 | y' = xSin(θ) + ycos(θ) 384 | */ 385 | xNew := x*cosAngle - y*sinAngle 386 | yNew := x*sinAngle + y*cosAngle 387 | 388 | // 平移回去 389 | xNew += cx 390 | yNew += cy 391 | 392 | return xNew, yNew 393 | } 394 | 395 | // 限制运行范围 396 | func (t *Tank) LimitTankRange(minXCoordinates, minYCoordinates, maxXCoordinates, maxYCoordinates float64) { 397 | if t.X < minXCoordinates { 398 | t.X = minXCoordinates 399 | } 400 | if t.X > maxXCoordinates { 401 | t.X = maxXCoordinates 402 | } 403 | if t.Y < minYCoordinates { 404 | t.Y = minYCoordinates 405 | } 406 | if t.Y > maxYCoordinates { 407 | t.Y = maxYCoordinates 408 | } 409 | } 410 | 411 | // 更新炮弹的移动 412 | func (t *Tank) updateProjectile() { 413 | 414 | for idx, projectile := range t.Projectiles { 415 | 416 | // 检查炮弹是否已经飞出去边界 417 | if projectile.X < 0 || projectile.X > monitor.ScreenWidth || projectile.Y < 0 || projectile.Y > monitor.ScreenHeight { 418 | // 删除炮弹 419 | t.removeProjectile(idx) 420 | continue 421 | } 422 | 423 | if projectile.IsExplode { // 炮弹已经爆炸 424 | if projectile.Frame > 16 { // 爆炸效果结束 425 | t.removeProjectile(idx) // 删除炮弹 426 | } else { 427 | projectile.Frame++ // 爆炸效果 428 | } 429 | continue 430 | } 431 | // 转为弧度 432 | angleRadians := projectile.Angle * math.Pi / 180.0 433 | // 水平和垂直分量计算 434 | offsetX := projectile.Speed * math.Cos(angleRadians) 435 | offsetY := projectile.Speed * math.Sin(angleRadians) 436 | // 累加 437 | projectile.X += offsetX 438 | projectile.Y += offsetY 439 | 440 | } 441 | } 442 | 443 | // 删除炮弹 444 | func (t *Tank) removeProjectile(index int) { 445 | // Ensure the index is within bounds 446 | if index < 0 || index >= len(t.Projectiles) { 447 | return 448 | } 449 | t.Projectiles = append(t.Projectiles[:index], t.Projectiles[index+1:]...) 450 | } 451 | 452 | ////////////////////////////////////// 坦克基本元素绘制 /////////////////////////////////// 453 | 454 | var ( 455 | projectileImage, _, _ = ebitenutil.NewImageFromFile("resource/projectile.png") 456 | explosionImg, _, _ = ebitenutil.NewImageFromFile("resource/explosion.png") 457 | ) 458 | 459 | // 绘制坦克各个元素 460 | func (t *Tank) Draw(screen *ebiten.Image) { 461 | 462 | t.drawTank(screen) 463 | t.drawTurrent(screen) 464 | t.drawHealthBar(screen) 465 | t.drawReload(screen) 466 | t.drawAttackCircle(screen) 467 | t.drawProjectile(screen) 468 | 469 | } 470 | 471 | // 绘制炮弹 472 | func (tk *Tank) drawProjectile(screen *ebiten.Image) { 473 | 474 | frameOX := 0 475 | frameOY := 0 476 | frameWidth := 64 477 | frameHeight := 64 478 | frameCount := 16 479 | for _, projectile := range tk.Projectiles { 480 | 481 | if projectile.IsExplode { // 绘制爆炸特效 482 | 483 | frameIndex := projectile.Frame % frameCount 484 | if frameIndex < 0 || frameIndex >= frameCount { 485 | continue 486 | } 487 | op := &ebiten.DrawImageOptions{} 488 | op.GeoM.Translate(projectile.X, projectile.Y) 489 | // 按照一列一列显示图片 490 | sy := frameOY + (frameIndex/4)*frameHeight 491 | sx := frameOX + (frameIndex%4)*frameWidth 492 | // 裁剪图片 493 | subImg := explosionImg.SubImage(image.Rect(sx, sy, sx+frameWidth, sy+frameHeight)).(*ebiten.Image) 494 | screen.DrawImage(subImg, op) 495 | 496 | } else { // 绘制炮弹正常飞行 497 | op := &ebiten.DrawImageOptions{} 498 | 499 | baseOffsetX := float64(projectileImage.Bounds().Dx()) / 2 500 | baseOffsetY := float64(projectileImage.Bounds().Dy()) / 2 501 | 502 | // 先平移图片(将图片的中心,移动到(0,0)位置) 503 | op.GeoM.Translate(-baseOffsetX, -baseOffsetY) 504 | // 旋转图片 505 | op.GeoM.Rotate(projectile.Angle * math.Pi / 180.0) 506 | // 再平移图片 507 | op.GeoM.Translate(projectile.X, projectile.Y) 508 | // 绘制图片 509 | screen.DrawImage(projectileImage, op) 510 | } 511 | 512 | } 513 | } 514 | 515 | func (tk *Tank) drawAttackCircle(screen *ebiten.Image) { 516 | 517 | clr := color.RGBA{255, 248, 220, 100} 518 | if tk.Enemy != nil { 519 | clr = color.RGBA{255, 69, 0, 100} 520 | } 521 | 522 | if tk.TkType == TankTypePlayer { 523 | // player 圆圈 524 | //vector.StrokeCircle(screen, float32(tk.X), float32(tk.Y), float32(tk.Turrent.RangeDistance), 1.0, clr, true) 525 | } else { 526 | startAngle, endAngle := (tk.Turrent.Angle-tk.Turrent.RangeAngle)*math.Pi/180, (tk.Turrent.Angle+tk.Turrent.RangeAngle)*math.Pi/180 527 | utils.DrawSector(screen, float32(tk.X), float32(tk.Y), 1.0, float32(tk.Turrent.RangeDistance), float32(startAngle), float32(endAngle), clr, true) 528 | } 529 | } 530 | 531 | // 坦克 532 | func (tk *Tank) drawTank(screen *ebiten.Image) { 533 | 534 | op := &ebiten.DrawImageOptions{} 535 | // 加载图片 536 | tankBody, _, _ := ebitenutil.NewImageFromFile(tk.ImagePath) 537 | 538 | baseOffsetX := float64(tankBody.Bounds().Dx()) / 2 // hullBody.Bounds().Dx() = 256 539 | baseOffsetY := float64(tankBody.Bounds().Dy()) / 2 // hullBody.Bounds().Dy() = 256 540 | 541 | // 先平移图片(将图片的中心,移动到(0,0)位置) 542 | op.GeoM.Translate(-baseOffsetX, -baseOffsetY) 543 | // 旋转图片 544 | op.GeoM.Rotate(tk.Angle * math.Pi / 180.0) 545 | // 整个绘制收缩了( 50 / 256)倍,即 1/5.12 546 | op.GeoM.Scale(1/ScreenToLogicScaleX, 1/ScreenToLogicScaleY) 547 | // 再平移图片到窗口的中心位置 ( 因为绘制收缩了,所以屏幕坐标需要增大) 548 | op.GeoM.Translate(tk.X, tk.Y) 549 | // 绘制图片 550 | screen.DrawImage(tankBody, op) 551 | 552 | } 553 | 554 | // 绘制炮塔 555 | func (tk *Tank) drawTurrent(screen *ebiten.Image) { 556 | 557 | op := &ebiten.DrawImageOptions{} 558 | turrentBody, _, _ := ebitenutil.NewImageFromFile(tk.Turrent.ImagePath) 559 | 560 | baseOffsetX := float64(turrentBody.Bounds().Dx()) / 2 // hullBody.Bounds().Dx() = 256 561 | baseOffsetY := float64(turrentBody.Bounds().Dy()) / 2 // hullBody.Bounds().Dy() = 256 562 | // 先平移图片(将图片的中心,移动到(0,0)位置) 563 | op.GeoM.Translate(-baseOffsetX, -baseOffsetY) 564 | // 旋转图片 565 | op.GeoM.Rotate(tk.Turrent.Angle * math.Pi / 180.0) 566 | 567 | // 整个绘制收缩了( 50 / 256)倍,即 1/5.12 568 | op.GeoM.Scale(1/ScreenToLogicScaleX, 1/ScreenToLogicScaleY) 569 | // 再平移图片到窗口的中心位置 ( 因为绘制收缩了,所以屏幕坐标需要增大) 570 | op.GeoM.Translate(tk.X, tk.Y) 571 | // 绘制图片 572 | screen.DrawImage(turrentBody, op) 573 | } 574 | 575 | // 血条 576 | func (tk *Tank) drawHealthBar(screen *ebiten.Image) { 577 | 578 | // 血量百分比 579 | percentage := float64(tk.HealthPoints) / float64(tk.MaxHealthPoints) 580 | 581 | // 血量颜色 582 | var filledColor color.RGBA 583 | if percentage >= 0.60 { 584 | filledColor = color.RGBA{0, 255, 0, 255} // Green 585 | } else if percentage >= 0.40 { 586 | filledColor = color.RGBA{255, 165, 0, 255} // Orange 587 | } else if percentage > 0 { 588 | filledColor = color.RGBA{255, 0, 0, 255} // Red 589 | } else { 590 | filledColor = color.RGBA{0, 0, 0, 0} // Transparent 591 | } 592 | 593 | // filledWidth 至少为1,不然下面的 NewImage函数报错 594 | filledWidth := 1 + int(tk.HealthBarWidth*percentage) 595 | 596 | newImage := ebiten.NewImage(filledWidth, int(tk.HealthBarHeight)) 597 | newImage.Fill(filledColor) 598 | 599 | op := &ebiten.DrawImageOptions{} 600 | // tk.X-25.5 左对齐坦克边缘 601 | op.GeoM.Translate(tk.X-25.5, tk.Y+30) 602 | screen.DrawImage(newImage, op) 603 | 604 | } 605 | 606 | // 重新装弹 607 | func (tk *Tank) drawReload(screen *ebiten.Image) { 608 | percentage := float64(tk.ReloadTimer) / float64(tk.ReloadMaxTimer) 609 | 610 | var filledColor color.RGBA = color.RGBA{128, 128, 128, 255} // grey 611 | 612 | if tk.ReloadTimer == tk.ReloadMaxTimer { // 满了 613 | filledColor = color.RGBA{255, 105, 180, 255} 614 | } 615 | 616 | filledWidth := 1 + int(tk.ReloadBarWidth*percentage) 617 | newImage := ebiten.NewImage(filledWidth, int(tk.ReloadBarHeight)) 618 | newImage.Fill(filledColor) 619 | 620 | op := &ebiten.DrawImageOptions{} 621 | op.GeoM.Translate(tk.X-25.5, tk.Y+35) 622 | screen.DrawImage(newImage, op) 623 | } 624 | --------------------------------------------------------------------------------