第一章 关于这本书

1.1 引言
欢迎来到《从零开始的游戏编程:Go 语言与 Ebitengine》。
本书旨在为完全没有编程经验的超初学者提供指导,以“连老妈都能理解”为目标,循序渐进讲解如何从零开始制作游戏。
希望通过本书,让你体会到游戏编程的乐趣。在这本书中,我们将最快的路线,从编程基础走到游戏开发。
· 极其易懂、细致解说如何配置编程环境
· 一边让画面动,一边学习编程
· 学习制作各种类型的游戏,都能用到的泛用技术
以上就是我们的目标。如果读者里面有:
· 环境构建太难搞,总是半途而废
· 黑窗口好无聊,啥时候才能有画面
· 我学的东西,真的对制作游戏有帮助吗
如果你这样想,恭喜,这本书正好适合你。另外本书还推荐给那些“游戏引擎很卡,在我的破电脑上跑不起来”的人。
笔者入门游戏编程,用的是名为 BASIC 的古老编程语言。尽管 BASIC 仍然是一种伟大的语言,但与现代游戏开发相距甚远。
在现代,能够实现前面这些这些宏伟目标的东西,笔者认为除了本书中提到的 Go 语言和 Ebitengine,可以说别无他选。
如果有人因此立志用 Go 和 Ebitengine 制作游戏,对我来说将是无上的喜悦。但即使不是这样(也就是说,用其他引擎做游戏或最终没做游戏方面的编程),我也相信在这里学到的知识,一定会对你有所帮助。
我虽然说了很多,但如果您能当作轻松的业余消遣来读,相信也一定会很有趣😉
1.2 这本书的使用方法
这本书不仅可以在 PC 上阅读,也可以在手机上阅读。不过,实际编程需要用 Windows PC 或 Mac。
中文版托管在 github.io 上。中文版翻译校对完成后,也会有 Epub版 与 PDF版。
(日文原版)虽然没有 Zenn 的账户也可以阅读,但如果登录 Zenn 账户,则会有阅读进度记录等功能,因此推荐注册。

代码确认是在 Windows 10、Windows 11 和 macOS Ventura 上进行的。如果有任何问题,请在以下社群中告知我们。
1.3 关于编程社群
您可以在 Ebitengine 官方 Discord 服务器的 #questions-ja 频道中,直接向作者 @eihigh 询问。 任何小问题都非常欢迎!
此外,我们还不定期举办轻松的学习交流会“Ebitengine ぷちConf”,最近的一次在 8 月 30 日举行。
详情请点击这里。欢迎初学者参加,请随时加入我们!
1.4 请您给予支持
全编免费阅读,但如果您从(日文版)书籍目录画面下方的“徽章(バッジ)”处给作者打赏,作者会非常高兴!
1.5 其他学习资料
学习 Go 语言的资料,除了这本书之外还有很多选择。以下是其中的一部分,希望对您有所帮助。
GO指南
A Tour of Go 是 Go 官方提供的学习内容(链接是志愿者翻译的中文版)。 它的特点,是提供了 Go Playground 功能(后面会介绍),一边在左侧阅读文字版解说,一边在右侧编写实际的Go程序,并在浏览器上即时运行。 虽然教程的内容,假定读者有一定的编程经验,但依然是快速掌握 Go 的绝佳选择。

Go Playground
Go Playground 是 Go 官方提供的代码执行环境。可以在浏览器上编写并执行 Go 程序。或通过 Share 按钮发布 URL,共享编写的 Go 代码片段。
不过由于 Go Playground 上运行的程序无法响应用户输入,因此不适合做游戏。适合快速检查语法、共享代码片段。

《开始使用 Go》微软出品的Golang 教程
虽然 Golang 是谷歌的产品,但却没有妨碍微软为Go写名为《开始使用 Go》的在线教程。

这个教程,支持包括中文在内的大多数语言。特定章节,还能切换对应Windows、Linux、MacOS的版本。毕竟很多人也在使用微软 Azure 云 部署 Go 服务。培养 Go 开发者,是个互利互惠的好主意。
《Go语言编程 完全入门》(日语资料)
《Go语言编程 完全入门》(链接为日文版的谷歌Doc)是由致力于 Go 普及的 tenntenn 先生编写的系列教程。
内容面向初学者,并且附有实际动手学习的实践环节,非常易于理解,推荐阅读。

1.6 学习时的注意事项
因为 Go 始终在不断发展,某些教程可能因为信息过时而导致“照着做却不成功!”。这本书也不例外,所以请尽量参考最新信息。
1.7 编程是什么,游戏编程是什么
程序究竟是什么?我将尝试以老妈也能理解的方式解释。
程序是指示计算机做事的工具。例如,编写指示计算机“按顺序显示 1 到 10 的数字”的程序,计算机就会按顺序显示 1 到 10 的数字。

个人电脑、智能手机、游戏机等,所有计算机都由程序控制。如果没有程序,计算机就只是一个空壳。
游戏编程,就是在编写作为游戏运行的程序。将图像显示在屏幕上、发出声音、对输入作出反应、移动角色等,这些都由程序控制与运行。
此外,游戏编程的范围,不限于我们手头的电脑、手机和游戏机上运行的程序。
互联网某处的“游戏服务器”、为游戏开发者提供的“辅助工具”、以及被各种游戏使用、支撑开发的“游戏引擎”等等,游戏编程涉及多个领域,在这里我们不扩展讨论了。
不过,现代游戏中这些内容也是不可或缺的,因此有意将游戏编程作为职业的人,建议您保持好奇心并积极学习。
在这个连载中,我们将从零开始学习游戏编程。请务必体验一下从无到有,写出一款游戏的感觉。
1.8 Go 语言与 Ebitengine 概述
在本连载中,我将简要介绍将要使用的技术。对技术话题感兴趣的读者可以阅读一下。
Go 语言是 2009 年正式发布的相对年轻的编程语言。语法简单、环境搭建轻松。具有脚本语言一样容易执行这样的“易用性”,与可以快速编译与执行速度快这样的“高性能”等特点。
这种语言,还因由 C 语言的发源地贝尔实验室的资深程序员开发而闻名,Go 语言一方面保留了 C 语言的特征,也在吸取 C 语言的经验和教训的基础上,去除了复杂性,是现代化的编程语言。
Go 主要用于 Web 与云服务领域,但 Go 当然也可以制作游戏。在这里,我们将使用 Go 加 Ebitengine 这个库(库:为了让大家分享,而编写的通用游戏组件)来制作游戏。
Ebitengine(エビテンジン)是由日本人星一(Hajime Hoshi)开发的一个用 Go 开发游戏的库。
它支撑了许多著名的作品,如《梅格与怪物》、《熊先生的餐厅》,以及备受期待的新作《SAEKO: Giantess Dating Sim》。 游戏引擎与 Go 有很多共通之处,比如简单易用,运行速度快。
Ebitengine 另一个值得大书特书的特点,就是其支持平台范围广了。
它不仅支持 Windows、macOS、Linux 等桌面环境,还支持使用WebGL在 Web 浏览器上运行 ,以及发布成为 iOS 和 Android 上的智能手机应用,甚至还能移植到 Nintendo Switch 和 Xbox 这些主机上。
它让用 Go 语言编写的游戏,可以轻松移植到各种平台。
Ebitengine 官方网站上发布了大量游戏示例,在浏览器上展示了游戏的的效果,代码可以直接复制粘贴运行。如果您有编程经验,可能只需浏览一遍示例就能掌握用法。请务必查看。

Ebitengine 是专注于 2D 游戏的引擎。
虽然不支持 3D(因此用起来简单),但也有像 Tetra3D 这样基于 Ebitengine 的黑科技 3D 引擎,感兴趣的话可以去看看。
1.9 许可证
Go gopher(GO吉祥物地鼠)图片,采用创意共享 4.0 署名许可。阅读本文,以获取更多详细信息:https://blog.golang.org/gopher
本教程用了 Egon Elbre 氏提供的 gopher 图像( https://github.com/egonelbre/gophers ),该图像以 CC0 1.0 Universal 许可提供。
本书的原始图像可以在个人学习范围内自由使用。
1.10 关于中文翻译
本书在原作者eihigh的许可下翻译,译文同样限定在个人学习范围内自由使用(也就是非商用)。
为了便于中文读者理解,译文中添加了一些段落,替换了部分截图,不完全与原文一致。
第二章 安装软件,配置环境
本章配合截图,将详细介绍搭建“编程环境”的过程。
2.1 必须的东西
首先,你需要一台装有 Windows 或 Mac (或 Linux)系统的电脑。哪怕是比较旧的设备也可以。
2.2 搭建环境前
为了在准备阶段不出错,首先要确认你当前的输入法,是否正确输入“半角字符”与“英文标点”。
大多数中文输入法,在中文输入状态的时候,默认输入中文标点。
使用日文输入法的用户,经常不小心输入全角字符。半角字符是宽度较窄的字符,而全角字符是宽度较宽的字符。

半角全角,中英标点,两者看起来都非常相似,但实际上是不同的字符。然后包括Golang在内,主流语言使用“半角字符+英文标点”。
在只接受“半角字符”与“英文标点”的地方输入“全角字符”与“中文标点”,会导致程序出错。所以在编写代码的时候,请确保你的输入模式在“半角字符+英文标点”状态。
| 应该使用 | 避免使用 |
|---|---|
A (半角字符) | A (全角字符) |
M (半角字符) | M (全角字符) |
(半角空格) | (全角空格) |
, (英文逗号) | , (中文逗号) |
幸好,后文提到的 VSCode 有个功能,就是用黄色方框圈出全角字符等需要注意的字符,因此即使不小心输错了,也能在检查时发现。真是个便利的时代。

2.3 用到的软件
本连载中使用的软件全部免费。心怀感激地下载回来吧。
首先是 Go。这是 Go 编程的必备[1]软件。这名字,简单直接。
另一个是名为 Visual Studio Code (VSCode) 的文本编辑器(文本编辑软件)。实际上,只要能打字就能编程,因此连记事本也能写程序。但 VSCode 里面有辅助编程的大量附加功能,很方便,所以强烈建议安装。
另外,如果您对编辑器有特别的偏好,也可以使用其他编辑器。作者使用的是 Vim,还有其他各种付费编辑器,如 GoLand 等。顺便提一下,写 C/C++ 程序常用的 Visual Studio 的名字和 VSCode 名字很像,但两个软件完全是不同的东西。
2.3.1 [面向有编程经验的人]关于git
在本系列中,可以“不安装”文件版本管理工具 git。
一些较旧的 Go(版本 1.13 之前)教程中,可能会有“必须安装 git ”这样的描述,但在某次 Go 更新以后,git 不再是必选项了。
2.3.2 [面向有编程经验的人]Go 不需要版本切换与管理软件
某些编程语言,会推荐使用版本管理工具来区分多个运行环境版本(比如 python )。但 Go 向下兼容性很好,基本没有这么做的必要。请放心直接安装最新版 Go 。
Go 有读取 go.mod 中记录的 Go 版本的功能。可以自动下载正确版本的依赖软件(不污染系统 PATH),将相关工具调整为合适的版本。
2.3.3 [面向有编程经验的人]GOPATH 和 GOROOT
现在的 Go ,不再需要手动设置环境变量 GOPATH 和 GOROOT 了。那些说需要设置的文章,成文时间比较早,建议不要参考。
2.4 Go 的安装
以下均为撰写时的屏幕截图,可能与最新页面有所不同,敬请谅解。
请访问以下链接(go.dev/dl),如果是 Windows,请点击红框部分。 如果是 Mac,请点击 Apple macOS (ARM64) (M1 以后的新Mac电脑) 或 Apple macOS (x86-64) (M1 之前的老Mac电脑) 下载安装程序。

下载安装程序以后,请运行文件。一直点击下一步即可完成安装。GO安装完可以直接用,无需重启系统。
动图演示:

2.4 [面向中国大陆用户] Go 的安装 与 GOPROXY
2.4.1 Go 的安装
中国大陆用户会面临一个尴尬的问题,就是“国外网站与github经常连不上”。
一个办法是系统全局挂代理。然后一切如常了。另一个办法,就是使用国内镜像。
首先,是 GO 安装包本身。大陆用户,可以访问以下链接下载go(https://studygolang.com/dl)
*此链接是中国的编程社区在国内提供的 Go 安装包镜像。与官方源相比,更新可能会晚几天。
同样的,Windows用户,点击上图红框处下载。
如果是 Mac,用Intel芯片的老机型,点击【Apple macOS macOS 10.15 or later, Intel 64-bit 处理器】。
最近出的M芯片Mac机型,则是【Apple macOS macOS 11 or later, Apple 64-bit 处理器】。
下载之后的安装过程与上面一样,一路下一步即可。
2.4.2 什么是 GOPROXY,如何设置GOPROXY
中国大陆用户还需要额外设置 GOPROXY,设置镜像服务器加速包括 ebitengine 在内的第三方包的下载。
目前推荐的 GOPROXY 设置为 https://goproxy.cn,direct。
配置中的 goproxy.cn 是一个由大陆 Go 社区维护的镜像服务器,它提供了对 Go 仓库的镜像服务,可以加速 Go 模块的下载速度。详情可以参考网站 https://goproxy.cn。
当你设置 GOPROXY 为 https://goproxy.cn,direct 时,Go 工具链会优先尝试通过 goproxy.cn 获取依赖,如果无法获取,才会回退到直接访问源仓库(direct)。
至于设置方法,如果你已经安装好了Go,可以用 Go 自带的命令更改GOPROXY的值:
go env -w GOPROXY=https://goproxy.cn,direct
go env -w 是一个用于修改环境变量(类似于设置值)的命令,Go 1.13 及以上的版本可用。
做的事,本质上和下面的2.4.3 与 2.4.3 相同。
2.4.3 [面向有经验的用户]windows 如何手动设置 GOPROXY
- 打开“开始”并搜索“env”
- 选择“编辑系统环境变量”
- 点击“环境变量…”按钮
- 在“<你的用户名> 的用户变量”章节下(上半部分)
- 点击“新建…”按钮
- 选择“变量名”输入框并输入“GOPROXY”
- 选择“变量值”输入框并输入
https://goproxy.cn,direct
动图演示:

2.4.4 [面向有经验的用户]macOS 或 Linux 手动设置 GOPROXY
echo "export GOPROXY=https://goproxy.cn,direct" >> ~/.profile
source ~/.profile
2.5 VSCode 的安装
VSCode 支持 Windows、macOS、Linux 等多种操作系统。
虽然这里只说明了 Windows 系统的安装方法,但其他操作系统的安装步骤,也基本上大同小异。
2.5.1 Windows 安装 VSCode
请访问以下链接( code.visualstudio.com),并点击 Download for Windows 。

运行下载回来的安装程序。
有的时候,会出现这样的弹出窗口,提示“当前安装包不适合为管理员安装,如果想为所有用户安装,请下载专用的安装程序”。在这里点击 OK 继续。

阅读使用许可并勾选同意,然后点击下一步。

确认安装位置,然后点击“下一步”。

会询问你是否在开始菜单中创建快捷方式,请根据您的喜好进行设置,然后点击下一步。

您可以自定义各种选项,根据您的喜好进行设置。个人认为 将[通过Code打开]添加到右键菜单 很方便,因此推荐启用这个功能。点击“下一步”以继续。

最后会显示你的设置,做最后确认。没问题的话,就点击安装进行安装。

点击完成,结束安装过程。安装后无需重启,可以立即使用。

动图演示:

2.5.2 VSCode 中文化
VSCode安装已经完成。但默认的界面语言还是英文。英语不熟练的话,用起来很麻烦,所以需要做一下中文化。
左边栏的图标中,有一个类似田 的图标(红圈)。点击此图标,打开扩展功能侧边栏。
![]()
在搜索框(红框)中输入 Chinese ,找到并选择 Chinese Language Pack for Visual Studio Code ,然后点击 Install 按钮,安装扩展程序。

安装完成后,右下角会出现提示重启的弹出窗口,请点击 Change Language and Restart 以重启 VSCode。

再启动后,菜单与界面语言变成了汉语。这样就完成了中文化。

动图演示:

2.5.3 VSCode 安装 Go 插件
像中文包一样,VSCode 可以通过安装插件来添加各种功能。当然也有方便 Go 开发的插件,快来安装吧。
与先前一样,打开扩展功能侧边栏,在搜索框中输入 go ,并查找 Go 插的相关件。会有很多结果,但请选择带有表示官方勾号的选项,并进行安装。

虽然,这项工作可以随时进行。但我一般会先完成 go 开发工具的安装。
从左下角的设置图标(红圈)选择“命令面板...”。或通过键盘快捷键“Ctrl+Shift+p”来打开命令面板。

下图的红框部分,是执行各种命令的面板。在输入框中,输入文字来搜索 Go: Install/Update Tools 这个项目,选择这个命令来执行。

在此处,选择要安装的go开发工具。因此请在输入框左侧的复选框(红圈)中勾选以全部选择,然后点击 确定 进行安装。

接下来会看到 VSCode 文本日志滚啊滚(如果大陆用户因为网络原因,装到一半中途提示安装失败的话,请多尝试几次 )。
如果最后显示 All tools successfully installed. You are ready to Go. :) ,就表示相关工具表示完成了。
这是一个将英语单词 Go 和 Go 结合的双关句。

动图演示:

至此,VSCode 的准备工作已全部完成。
2.6 打开目录
要开始开发,需要创建一个放工作文件的目录。目录与“文件夹”是同义词,但在本系列中,我们将尽量遵循 Go 官方的用法,使用“目录”一词。
2.6.1 创建目录的方法 (Windows)
这里将说明如何在桌面上创建目录。
首先在桌面空白处右键点击,选择“新建”子菜单下的“文件夹”。

接下来会要求输入目录名,请输入您喜欢的名称。不过,为了避免欧美软件出现问题,应该仅使用基本的半角英文或数字。在这里,我们用了 test 。

输入目录名后按回车键确认。这样就创建了目录。
目录名称可以通过右键单击目录后选择“重命名”来更改。

2.6.2 用 VSCode 打开目录
创建目录后,从 VSCode 左上角的“文件”中选择“打开文件夹...”,打开文件夹选择界面。

在文件夹选择界面,选择刚刚创建的目录,然后点击“选择文件夹”以打开。

打开目录时,会显示这样的注意事项。这是为了防止误打开危险文件,但由于这是自己创建的目录,所以没有安全问题。点击“是,我信任此作者”。

打开目录,如果左侧的资源管理器(如果未显示,请点击左端的📄图标(红圈部分))中显示的目录名称(红框。目录名自动大写显示,这没有问题),则表示成功。

此外,如果在安装 VSCode 时勾选了 添加[通过 Code 打开] 到右键菜单 ,也可以通过右键单击目录并选择“通过 Code 打开”来打开。

动图演示:

2.7 打开终端
接下来的教程,将频繁使用 VSCode 的“终端”功能,因此我们要学会它的使用方法。
可以通过菜单栏的“视图”选择“终端”来打开终端(还有其他打开方式)。终端中显示的内容,因操作系统和设置而异。

终端是一个用于“文本”而非“鼠标”操作计算机的工具。可能有些人想起来程序员常常面对的那些神秘黑屏。
没错,就是那玩意。

终端是一个在鼠标诞生前就存在的古老工具,至今仍然受到程序员的青睐。
· 记住或拼接命令很困难,但一旦准备好了,批量执行的操作就会变得很简单
· 鼠标只能使用应用程序中存在的功能,但在终端[2]中,可以组合手头的通用工具,创造性地发明需要的新功能
由于有诸如这些的好处,终端最开始可能会让人很头大。但如果你能逐渐熟悉终端,我会很高兴。
顺便提一下,终端的黑色背景,仅仅是一个历史留存。在现代,很多人会设置成更时髦的配色,如果你感兴趣,可以去查一下怎么改。
2.7.1 确认 Go 已经装好了
为了练习终端,我们来确认一下此时 Go 是否能够正常使用吧。
在终端中以半角输入 go version ,然后按下回车键。如果显示了 Go 的版本信息,则表示成功!🎉 这里显示的是 go version go1.21.4 windows/amd64 ,但根据您的环境,内容可能会有所不同。
命令可以从本文中复制粘贴。如果复制粘贴并输入正确的命令但没有显示版本,则表示安装未正确完成,请再次确认上面的“Go 安装”部分。
2.7.2 使用历史命令
用终端时必备的技巧是“历史命令”。按上箭头键,可以显示刚刚输入的命令,利用这个技巧,可以调用过去的命令。连续按上箭头键可以回溯到更早的命令。如果回溯过头,可以按下箭头键返回。
“上方向键,并按回车”是终端操作中节省时间的基本技巧,值得记住!
2.7.3 玩一玩示例游戏
为了纪念您学会使用终端,我们来玩一下 Ebitengine 的示例游戏吧。
只需输入这条命令,然后敲一下回车即可。因为命令有点长,建议复制粘贴。此外,首次启动可能需要一些时间。
go run github.com/hajimehoshi/ebiten/v2/examples/flappy@latest
使用此命令可以运行 examples/flappy。请注意音量。

笔者的华丽表现
要注意不要玩得太多,偏离游戏开发正题!
2.7.4 关于终端画面用法的补充
以下是 Windows 终端屏幕的截图。红线标记的文本中, test 之前的是当前目录(也称为工作目录), > 是命令提示符。
当前目录表示“当前工作位置”,命令提示符表示终端处于“输入接收状态”。在这个提示符后面输入各种命令,是终端操作的基础用法。

当前目录,基本上与在 VSCode 中打开的目录相同,但有时可能需要移动到其他位置。移动时使用 cd 命令,如果需要,请展开以下说明进行阅读。
2.7.5 cd 命令的用法
终端中显示的当前目录,初始状态下与在 VSCode 中打开的目录相同,但可以通过后续的 cd 命令等进行移动。
cd 命令是通过提供文件路径(也称为路径)来移动到该位置的命令。文件路径是用反斜杠 \ 或斜杠 / 分隔的,表示文件或目录位置的文本。文件路径的语法因操作系统而异,因此将分别进行说明。
Windows 下的 cd 命令
Windows的文件路径,分割字符是反斜杠 \ 。不过这个字符比较麻烦,在某些日文环境中可能会显示为圆形标记 ¥ 。
Windows 的完整文件路径从 C: 等驱动器字母开始。例如,用户 tarou 的桌面上的 myprogram 目录是 C:\Users\tarou\Desktop\myprogram 。完整的文件路径称为绝对路径或全路径。
既然有完整的文件路径,当然也有不完整的文件路径。以普通字符开头的文件路径称为相对路径,表示基于当前目录的文件路径。例如,如果当前目录是 C:\Users\tarou\Desktop ,则相对路径 myprogram 指向 C:\Users\tarou\Desktop\myprogram 。
.. 是一个特殊的文件路径,表示当前目录的父目录的相对路径。通过像 ..\.. 这样用分隔符连接,可以表示父目录的父目录。此外,通过像 ..\myprogram 这样将 .. 和普通的相对路径组合,可以在这种情况下指向“父目录的孩子”,即兄弟。
. 也是一个特殊的文件路径,指向当前目录本身。因此, .\child 和 child 指向同一个“当前目录中的 child ”。看起来似乎没有什么意义,但实际上相当常用。
让我们通过实际例子来复习一下。当前目录为 C:\Users\tarou\Pictures 时,要使用相对路径移动到 C:\Users\tarou\Desktop\myprogram ,请输入以下命令。
cd ..\Desktop\myprogram
觉得无法一次弄清楚的话,就分拆成一小块,分步解读吧。
- 最开始的
..的意思,就是移动到C:\Users\tarou\Pictures的父文件夹C:\Users\tarou里面。 - 接下来的
Desktop,会进一步移动到C:\Users\tarou的下一层级,也就是子文件夹C:\Users\tarou\Desktop里面。 - 最后的
myprogram,会继续移动到下一个子文件夹,也就是C:\Users\tarou\Desktop\myprogram里面。
如果使用绝对路径直接移动,这条命令会变成这样:
cd C:\Users\tarou\Desktop\myprogram
macOS(或 Linux) 下的 cd 命令
macOS 文件路径的分割符是斜杠 / 。此外,绝对路径也从 / 开始。例如 /Users/tarou/Desktop 等。
除了相对路径的分隔符与 Windows 的不同,macOS 的相对路径的规则,基本上与windows差不多,因此省略。
我们来看看实际的例子。当前目录为 /Users/tarou/Download 时,要使用相对路径移动到 /Users/tarou/Desktop/myprogram ,请输入以下命令。
cd ../Desktop/myprogram
如果使用绝对路径直接移动,则会变成这样。
cd /Users/tarou/Desktop/myprogram
还有许多与 Windows 的细微差别,但由于数量众多,无法一一列举。总体而言,我认为比 Windows 更易于理解。
2.7.6 命令提示符 "$"
在解释终端操作时,习惯在命令前加上提示用的字符 $ 以进行强调,并区分输入和输出。
例如, go version 命令的用法,用文本说明如下:
$ go version
go version go1.21.4 windows/amd64
第一行开头的$ ,是提示用的“命令提示符”。
第二行的 go version 是输入的命令。第二行不以 $ 开头,因此是程序的输出。
同时您的环境,可能使用别的字符(比如下面的动图,命令提示符的是>)。并且请注意不管$还是>,都只是提示,不是您输入的文本。

2.7.8 停止命令
有时,您可能想中断那些耗时较长的命令。这种情况下,可以在终端中按“Ctrl+c”,中断程序的运行。

本章总结
- Go 是编程的必备工具。
- 编写 Go 代码的文本编辑器,推荐使用 VSCode。但如果有偏好,用其他文本编辑器也可以。
- 和终端交个朋友。
- 正确搭建环境并学会终端的用法后,继续下一步吧。
注释
- [1] 严格来说是错的,还有其他工具可以解释 Go 语言。
- [2] 更确切地说,这东西应该叫 shell。
第三章 你好,世界!(开始开发,程序结构)
这次我们将通过一个仅显示 Hello, World! 的经典程序,学习如何开始编程,以及编程的基本工作流程。
3.1 程序与源代码
人们有时会特地强调文本特性,将生成程序的文本,称为“源代码”或“代码”。这些术语,基本上指的是同一个东西,请根据需要在脑中替换。
3.2 创建放程序的地方
首先,需要一个编写程序的地方。
让我们来创建一个新的目录。名字随便起,但这一章暂时用 hello 当名字。

创建目录后,在 VSCode 中打开它。
3.3 初始化模块
打开目录后,接下来需要初始化模块。关于模块的细节,稍后会进行说明,请打开终端并输入以下命令。
go mod init hello
如果在资源管理器中生成了名为 go.mod 的文件(红框),则模块初始化成功。
如果不成功,可能是命令敲错了、Go 安装失败,或当前目录与 VSCode 打开的目录不同。

3.4 编写程序
好了,现在我们有了放程序的地方。
接下来创建文件。方法可以有很多,但在这里,我们选择在 VSCode 的资源管理器上空白处右键点击,然后点击“新建文件...”来创建。

接下来会要求输入文件名,这里输入 hello.go ,并按下回车键确认。
.go 部分称为扩展名,是标识文件类型的重要部分。正确输入扩展名后,文件左侧的图标也会变成看起来像 “GO” 的图标。

在创建的文件中输入以下文本。如果觉得麻烦,可以直接复制粘贴下面的内容。另外,像往常一样,请确保输入法为半角状态。
package main
import "fmt"
func main() {
fmt.Println("Hello World!")
}
输入后,按“Ctrl+s”保存。这样就写好了第一个程序。

如果下图所示的红色波浪线(在这里位于 { 的前面)显示出来,则表示该位置存在错误。如何处理错误,将在后面的章节说明,请再次确认输入的程序是否正确。

3.4 运行程序
现在执行程序。请在终端中输入以下命令。
go run .
go run 是执行程序的 Go 命令, . 的意思是当前目录。合并后,意思是“执行当前目录的程序!”
执行命令后,如果终端输出 Hello, World! 则表示运行成功。恭喜您!🎉
前回说明的那样,如果想要多次执行相同的命令,可以依次按下“上箭头、回车”。
3.5 编辑代码
如果程序顺利执行,那么这次我们尝试将程序修改为显示另一种语言。我们将用以下内容,改写之前写的程序。
package main
import "fmt"
func main() {
fmt.Println("跨越长城,走向世界!")
}
修改的是 hello world! 的部分。
*构成程序的主要字符,必须是半角字符。但夹在 "" 之间的部分(称为字符串)等可以使用全角字符。具体情况稍后说明。
使用 go run . 执行程序时,应该会显示如下内容:

成功显示文字了吗?
如果做到的话,请尝试试着显示更多不同的消息吧。
3.6 处理错误
“错误” 这个东西,将会长期陪伴你。编辑器报告错误,绝不是在责怪你。
它会温柔地教你错误的原因和修正方法,甚至比学校的老师更亲切。
在 VSCode 中查看错误信息的方法:
-
在红色波浪线下方,用鼠标光标悬停时,会出现“悬停窗口”。
-
编辑器底部状态栏,点击
✕ 1 ⚠ 0的部分,可以显示“问题”标签。
总之,有很多手段可以查看错误信息。


这里的提示 expected '(', found '{' ,意思是“接下来应该是 (,但实际的符号确实 { ”。实际上,这是编辑器在告诉你在 main 后面应该接 () 。明白这一点,就可以修正这个问题。
英语提示,可能会让人感到害怕,但就算英语半生不熟,也可以连蒙带猜,弄懂大体上的内容。如果报错内容很复杂,也可以将错误信息完整地粘贴到 Google 中搜索,很可能会有人提供解决方案。
如果仍然感到怵头,可以利用像 ChatGPT 这样的 AI,向他们提问,并附上错误的信息和程序的文本,如果仍然有不明白的地方,可以继续和AI对话,逐步分析错误的原因。
“报错”并不是要责怪你做了坏事。**与其因为害怕出错而烦恼,不如先把想不通的部分写出来试试。**通过“出错”来学习,才是正确与高效的做法。
无论多么熟练的程序员,都很难一次写好想要的程序。只要是人,都需要依赖“犯错”来寻路。
(其实,对于熟练的程序员来说,“有错误却没提示”的情况更让人害怕。不骗你。)
请不要害怕错误,让“错误提示”成为你的趁手工具。
明明没写错,为什么却一直提示有错?
尽管如此,有时由于错误检查程序的 bug 或其他原因,即使是正确的代码,也可能无端报错。
这种情况下,从命令面板(快捷键 Ctrl+Shift+p)中选择 Go: Restart Language Server 通常可以解决问题。
相反,如果这样做仍然无法解决,很可能是因为你漏看了程序中的某些问题。
3.7 基本的开发流程
到目前为止,我们已经掌握了基本的开发流程。这个流程在今后也不会改变。
-
首先创建目录,并初始化模块。
-
创建 .go 文件,编写程序。
-
编辑程序。
-
执行程序。
-
如果有报错,请阅读错误提示,并修正代码。
-
反复执行“编辑、执行和修正”的循环,完善程序。
3.8 理解程序
刚才我们通过复制粘贴,体验了一把写程序的过程。接下来,我们要了解程序的细节。不过很难解释清楚每一个细节,现在只需了解大致即可。
此外,今后也会时不时出现**“声明/declaration”**这个词。
简单来说,就是指创建某个东西,告诉电脑**“这里有一个名为〇〇的东西!内容是XXXX!”**的意思。
稍微有点难以解释或翻译呢……有个大致的印象就可以了。
package main // 声明一个包
import "fmt" // 导入模块
func main() { // 函数声明
fmt.Println("Hello World!") // 调用函数
}
package main 表示该程序属于 main 包。
包是将程序捆绑在一起的单位,位于同一目录中的程序,基本上属于同一包。
import "fmt" 是用于导入其他包功能的导入语句。 fmt 包提供了在屏幕上显示文本的 Println 等函数。
func main() { 到 } 是 main 函数的声明。函数是指某些处理的集合。
main 包的 main 函数,是一个特殊的函数,表示程序的起点。可以认为在 go run . 中执行的处理,就是去运行这个 main 函数。
你可以试着将包名(package main)或函数名(func main() )中的任意一个从 main 改为其他的名字,再次运行 go run .时应该会提示错误,无法正常执行。
突然出现了很多新术语,相关细节稍后解释,现在只需了解“**原来有这样的东西”**就可以了。
3.9 程序注释
// 到行末的部分,是程序中不被识别的注释(或者说“备注”)。注释内容也可以使用全角字符。
虽然无论有没有注释,都不会影响程序的执行。但清晰的注释,可以使程序(对人来讲)更易读。
使用 /* */ 可以写多行注释(块注释)。与 // 相比,使用的机会较少,但偶尔还是会用到。
这不是注释 // 这是单行注释
这也不是注释
/*
多行注释
不止一行
也叫做“块注释”
*/
// 在 GO 程序里面
// 很多人习惯
// 用好几行单行注释
// 来代替多行注释
// (仅是个人感想)
评论具有“不影响程序执行”的特性,还可以作为“文档”或“指示”,用于除注释以外的目的,。这种注释的用法,将在合适的时候说明。
本章总结
- 在 Go 中创建新程序,需要创建目录并初始化模块。
- 「编写代码,用
go run .上运行,修正代码」是基本的编程流程。 - 错误是和蔼的老师,值得尊敬与感激。如果搞不明白,可以求助 AI。
第四章 做算数(公式、变量和函数)
这一章,将指挥电脑做算术,逐渐熟悉编程。
4.1 写程序的地方
前回我们在 hello 目录里面,创建了第一个程序。之后你可以重复使用该目录,也可以新建一个别的目录。请按照您的喜好选择。
如果要新建目录,请参考上一章的内容,用 VSCode 打开目录,再初始化 go mod 。
4.2 示例程序的说明
下面的程序里面的 package main 和 import "fmt" 等等,在不需要重复说明时会被省略,但实际上代码还是需要写的,请不要删除。
4.3 显示数字
想要显示数字,请在 fmt.Println 函数中的 () 内容设置为数字。
package main
import "fmt"
func main() {
fmt.Println(42)
}
$ go run .
42
4.4 计算式
Go 中,可以使用的主要的算术运算符如下所示。
加法和减法大家很熟悉,但其他符号可能会让你感到新奇。
这些都是编程世界中常见的符号。尤其是日常中不太常见的“取余”计算,在游戏编程中经常会用到。
1 + 2 // 加法
1 - 2 // 剑减法
1 * 2 // 乘法
1 / 2 // 除法
1 % 2 // 割求余
这些计算,也有“优先级”这个概念。乘法、除法、取余的优先级,高于加法和减法。此外,也可以使用括号 () 来改变默认的优先顺序。
func main() {
fmt.Println(1 + 2*3) // 7
fmt.Println((1 + 2) * 3) // 9
}
Go 提供了一种根据优先级等前提条件,自动设置代码格式(比如间距多少,在何处不换行)的格式化功能(go fmt)。
虽然无法设置自己喜欢的代码风格,但因为可以“让所有人都把代码轻松地格式化成标准格式,方便所有人阅读”,我们应该接受这一点。
4.5 小数计算
在 Go 中,整数和小数有明确的区别。整数与整数的计算,结果会去掉小数点的部分,变为整数。例如 1 / 2 的结果是 0 。
func main() {
fmt.Println(1 / 2)
}
$ go run .
0
进行小数计算时,请明确使用小数(也叫做浮点数),例如 1 应该写成 1.0 。
func main() {
fmt.Println(1.0 / 2.0)
}
$ go run .
0.5
[面向有经验的人] 浮动小数与字面量
字面量(包括 Go 中的常量)是一种与普通数值类型略有不同的类型( untyped ),在类型转换方面,会比普通类型宽松一些。
此外,浮动小数点常量在编译时以任意精度(实际上,由于实现上的原因为 256 位)进行计算,因此基本不需要担心误差等问题。
4.6 变量
使用变量, 可以为数据命名并临时存起来(虽然保存了,但仅在程序运行期间可用)。通过使用变量,可以多次使用相同的数据。
以下是变量最简单的用法。
package main
import "fmt"
func main() {
level := 50
fmt.Println(level)
}
$ go run .
50
将值保存到名为 level 的变量中,并显示这个值。
声明与赋值
特殊符号 := ,同时声明和赋值变量的记号。声明是“新建”数据,赋值是“覆盖”已有数据。为了更深入地理解这两个词,我们来看一下“声明和赋值分开写”的语法。
先声明,后赋值的语法:
func main() {
// 先声明
var level int
// 然后代入数值(赋值)
level = 50
fmt.Println(level)
}
Go以 var 参数名称 参数类型 的语法形式声明变量、用 变量名 = 值 的形式赋值。
关于变量类型这个概念,稍后会解释。总之此处的 int, 代表的是整数类型。
变量声明(新建)只能做一次,但赋值(覆盖)可以多次重复很多次。
func main() {
var level int
// var level int ←无法用同一个名字再次声明
level = 50
fmt.Println(level)
level = 100
fmt.Println(level)
}
[面向有经验的人] 作用域
Go 变量(或者说所有标识符)有作用域(有效范围)。作用域由块( {} 包起来的部分)分隔。在作用域内声明的变量,仅在该作用域内有效,无法从作用域外访问。
不能重复声明变量,严格意义上来说是“在同一作用域内”不能再次声明同一变量。
但在不同作用域中,可以声明同名的变量。重名的时候,更窄的作用域中声明的同名变量,会优先生效。因此范围更广的变量会被隐藏,在该作用域内无法访问。这被称为变量遮蔽。
编程中,变量遮蔽(英语:variable shadowing,或称变量隐藏)指的是:当新变量与旧有变量同名,此名称暂时不再可用于访问旧有变量。
不过,在大多数情况下,变量的声明和赋值会同时完成。同时做声明和赋值的时候,可以省略变量类型,写起来更省事。
无论用哪种语法,变量总是可以多次赋值,这一点不会变。
func main() {
var level = 50 // 声明和赋值同时进行,此时,表示类型的 int 可以省略不写
fmt.Println(level)
level = 100 // 再次赋值
fmt.Println(level)
}
此外,在函数内部,可以使用符号 := 来省略 var 这个关键字,使声明和赋值的语句更简洁,这是最常用的语法。
刚刚的程序,声明和赋值语句的等效写法:
func main() {
level := 50 // 声明与赋值
fmt.Println(level)
level = 100 // 再次赋值
fmt.Println(level)
}
4.7 变量和计算式
变量可以像普通数字一样,混用在算式中、参与计算。
func main() {
level := 50
waza := 100 // 盾甲龙兽
attack := 182
defense := 189
maxDamage := level*2/5 + 2
maxDamage = maxDamage*waza*attack/defense/50 + 2
fmt.Println("最大伤害", maxDamage) // 最大伤害 44
}
此外,还有一种同时“计算”与“赋值”的省略写法:
func main() {
hp := 100
hp += 20 // 与 hp = hp + 20 的意义相同
fmt.Println(hp) // 120
hp -= 50 // 与 hp = hp - 50 的意义相同
fmt.Println(hp) // 70
// *=, /=, %= 也遵循同样的规则
}
hp = hp + 20这个等式,在数学中不成立。但编程领域的=号,其实是表示代入(覆盖)的符号。请把这里的等号,当作和数学里的等号不同的符号来看。如果实在无法适应,请在心中将=替换为⇐(表示数据流动方向的箭头) 。
4.8 函数
函数是处理的集合。例如 fmt.Println 函数,是“执行在屏幕上显示文本的处理”的函数。
函数可以使用 () 接收输入。这些输入称为参数。给函数提供输入,称为传递参数(简称“传参”)。
同时传递多个参数,需要用逗号 , 分隔。例如 fmt.Println 函数,就可以把多个参数用空格连接,并把这些内容显示到屏幕上。
fmt.Println("哈哈", "呵呵")
$ go run .
哈哈 呵呵
函数不仅接受输入,还会输出计算或处理的结果。这被称为返回值。
fmt.Println 函数没有返回值,因此我们介绍另一个新函数——
max 函数,返回参数中最大值。
func main() {
fmt.Println(max(3, 4, 5)) // 5
}
这里的 () 有两层,可以简单地认为, max(3, 4, 5) 的计算完成后,fmt.Println()括号里面将被替换为 5 。
fmt.Println(max(3, 4, 5))
// max(3, 4, 5)的计算完成之后、↓将会等效于下面的式子
fmt.Println(5)
参数和返回值,可以混在一起用。
看一下这个程序,让我们来思考一下 fmt.Println 函数会拿到几个参数。
func main() {
fmt.Println("最大值是", max(3, 4, 5), "。最小值", min(3, 4, 5), "。")
}
本章总结
- 虽然和数学符号不一样,但在 Go 里面,也可以做算术。
- 算式有优先级这个概念。可以通过括号,调整优先级顺序。
- 使用变量可以给数据起名,临时保存数据。
- 通过传递参数和接收返回值,可以调用函数(将一些处理,整合在一起处理块)。
第五章 显示随机数(包与导入)
这次,我们将通过一个显示随机数的程序,学习为了使用 Go 丰富多彩的功能、必须掌握的“包与导入”两个概念。
5.1 显示随机数
以下的程序,可以显示从 0 到 5 的随机数。
package main
import (
"fmt"
"math/rand/v2"
)
func main() {
fmt.Println(rand.N(6))
}
结果每次都不同,以下是一个运行程序的例子。
$ go run .
3
$ go run .
0
重点:在 Go 等语言中,数列通常从 0 开始。例如, rand.N(3) 会随机返回 0、1、2 这三种数字中的一个。这种计数方式在日常生活中不太常见,但在编程中实际上更为方便。
[面向有经验的人] 随机种子
Go 的 math/rand/v2 包的全局函数生成的随机数,每次都不一样(随机化)。这是因为这样在安全性上更为可取。
但在游戏中,固定随机数的种子值,以实现“可再现性”是常见的做法。例如,可以通过仅保存种子值,而不是所有数值来重现世界。比如在《我的世界》中,通过相同的种子值,可以让玩家游玩同一世界,甚至像著名的《德鲁亚加之塔》,也有将随机种子融入游戏设计中的美谈。
在 Go 中生成种子固定随机值的方法,是将种子值传递给 rand.NewPCG 函数或 rand.NewChaCha8 函数,以创建随机数生成器。
5.2 包和导入语句
包是将 Go 程序捆绑在一起的单位。此外,通过导入,可以引入其他包的功能。
下面这个程序,导入了 fmt 包和 math/rand/v2 包。 math/rand/v2 包是处理随机数和随机数的包。
import (
"fmt"
"math/rand/v2"
)
上面的两个包被 () 起来。在语法上,上面的代码,与下面这两行导入语句的意思相同。
import "fmt"
import "math/rand/v2"
5.3 导入路径和包名
导入语句中写的字符串,被称为导入路径。
导入路径是指示包的位置的东西。 "fmt" 和 "math/rand/v2" 就是其中的例子。
在程序中使用包的功能,需要在包名后加上点 . ,像 fmt. 和 rand. 这样写。
有像 fmt 这样,导入路径(最后一个单词)和包名相同的例子。但也有像 math/rand/v2 和 rand 那样,包名与路径稍微不同的时候。
这方面没有严格的规范,所以让我们随意一点吧。
5.4 查看文档
全世界的公开 Go 包的文档都汇集在 pkg.go.dev 这个网站上。对于使用 Go 编程的人来说,这是必不可少的工具。因此如果未来想找未知的包,请务必参考。 不过,这些包的说明,大多数是用英文写的。因此建议使用浏览器的扩展功能翻译阅读。
上图是 rand.N 函数的文档。有的包,可能会附带示例程序。
[面向有经验的人] 导入路径和包名、模块的详细信息
如果导入路径以 github.com/ 等域名开头,那么这个包将从互联网上获取。
如果不是,该包是 Go 的标准包,将从 GOROOT(Go 的安装位置,可以用 go env GOROOT 这个命令确认)的 src 目录下获取。
导入路径以斜杠分隔的最后一个元素通常是包名,但这个规则并不是绝对的。
- 以
/v2结尾的包表示版本 2。主版本变化,意味着新版本不兼容旧版本,因此 Go 建议通过**更改导入路径(这里就是加了一个/v2)**来区分版本。 - 也有像
github.com/mattn/go-sqlite3(此包的包名是sqlite3)这样,包名与路径不一致的情况。根据作者的需要,可以自定义导入路径和包名。
本章总结
- 通过使用软件包,可以使用各种功能。
- 要使用包,首先需要指定导入路径(
math/rand/v2等)并进行导入。 - 使用包名(
rand等)调用包的功能。
第六章 玩抽签(条件分支)
条件分支,说的是“如果△△,那么〇〇”的这样的逻辑判断以后,再根据前提条件,决定走哪一条处理流程。
从这里开始,将会越来越有“编程”的味道。这次我们将通过制作抽签游戏,学习条件分支的用法。
5.1 if 语句
首先,我们来写一个简单的程序,随机决定是中奖还是没中奖。
package main
import (
"fmt"
"math/rand/v2"
)
func main() {
if rand.N(2) == 1 { // rand.N(2) 的返回值可能是0或1
fmt.Println("您中奖了!")
} else {
fmt.Println("很遗憾,没中")
}
fmt.Println("抽奖结束")
}
由于用了随机值,所以程序的输出,在每次执行时都会变化。
$ go run .
您中奖了!
抽奖结束
$ go run .
很遗憾,没中
抽奖结束
$ go run .
很遗憾,没中
抽奖结束
if是用于进行条件分支的关键字。通过像if △△ { 〇〇 }这样写,当满足△△的条件时,仅执行{ 〇〇 }的内容。==是用来检查左右值是否相等的符号。- 通过写成
else { ×× }的形式,当不满足if △△的条件时执行{ ×× }。 - if ~ else 结束后的代码(这次是
fmt.Println("抽奖结束")),与if无关,因此始终会执行。
结果是,“如果 rand.N(2) 的返回值等于 1,则显示为中奖,如果为 0,则显示为未中奖。最后始终显示抽奖结束。”
else if
判断完一个条件,如果信息量还不足够,并希望确认另一个条件成立不成立,此时应该使用 else if 。
package main
import (
"fmt"
"math/rand/v2"
)
func main() {
if rand.N(2) == 0 {
fmt.Println("很遗憾,没中")
} else if rand.N(2) == 0 {
fmt.Println("2等奖")
} else {
fmt.Println("1等大奖!!!")
}
}
$ go run .
很遗憾,没中
$ go run .
1等大奖!!!
$ go run .
2等奖
- 如果满足最初的条件
if rand.N(2) == 0,将只执行最初的{}里面的内容,并显示很遗憾,没中。 - 如果最初的条件未满足,会继续检查
else if rand.N(2),如果满足条件,则显示2等奖。 - 如果第二个条件也没有满足,则执行最后的
{}的内容,并显示1等大奖!!!。
布尔值
if △△ 的“△△”中,对应的内容称为布尔值(英文为布尔/boolean,简称布尔/bool)。布尔值是只有真/true 或假/false 两个选项的特殊值。
普通数字可以是 0, 1, 2, 3...等无限多,但布尔值只有“真”和“假”这两种。
if △△ { 〇〇 } else { ×× } 这个语句,指**“如果△△的真布尔值为真,则执行〇〇;如果为假,则执行××”**。
然后, == 是一个求真值的符号,如果左右的值相等,整个式子则为 true ,如果不相等整个式子为 false 。
还有其他的求真值的符号,如 > (大于)、 && (且)、 || (或者)等多种。这些可以组合在一起进行判断。我们会在需要的时候再讨论这些。
[面向有经验的人] Go 不能使用其他类型代替布尔值
Go 不能将用“布尔值”以外的类型代替布尔值。例如,在 C 语言中,0 代表假,其他值被视为真。
但在 Go 中,0 不能用来代替布尔值。必须使用逻辑运算符将其转换为布尔值。
5.2 switch语句
控制条件分支,除了 if 语句以外,也可以使用 switch 语句。 switch 在需要判断多个条件时非常方便。
switch 有几种语法,最基本的模式是“根据 switch 后面的值与哪个 case 相等,进行分支判断”。
package main
import (
"fmt"
"math/rand/v2"
)
func main() {
switch rand.N(3) {
case 0:
fmt.Println("是0")
case 1:
fmt.Println("是1")
default:
fmt.Println("其他数字")
}
}
运行结果示例:
$ go run .
是1
$ go run .
其他数字
$ go run .
是1
$ go run .
是0
当 switch 后面的值与某个 case 匹配时,将执行 case 的内容。
如果与任何 case 都不匹配,将执行 default 。
[面向有经验的人] go 的 switch 不需要 break 语句
与 C 语言等不同,Go在每个 case 的末尾不需要写 break ,而是默认可以直接跳出 switch 语句,不进入下一个 case 。
用 case 合并分支
在 Go 中,可以通过在 case 后面用逗号分隔,列出多个值来合并 case。
package main
import (
"fmt"
"math/rand/v2"
)
func main() {
switch rand.N(3) {
case 0, 1:
fmt.Println("是0或1")
default:
fmt.Println("其他数字")
}
}
$ go run .
是0或1
$ go run .
其他数字
$ go run .
是0或1
$ go run .
其他数字
用 switch 来代替 else if
此外,Go 语言还有一种特有的语法,即在 case 后面写条件而不是值。
func main() {
switch { // 这种写法里面,switch后面没有值
case rand.N(2) == 0:
fmt.Println("没中")
case rand.N(2) == 0:
fmt.Println("3等奖!")
case rand.N(2) == 0:
fmt.Println("2等奖!")
default:
fmt.Println("1等大奖!")
}
}
上述内容用 else if 重写,会变成这样的程序。两者的意思相同。请使用您喜欢的方式。
package main
import (
"fmt"
"math/rand/v2"
)
func main() {
if rand.N(2) == 0 {
fmt.Println("没中")
} else if rand.N(2) == 0 {
fmt.Println("3等奖!")
} else if rand.N(2) == 0 {
fmt.Println("2等奖!")
} else {
fmt.Println("1等大奖!")
}
}
5.3 制作抽签程序
那么,利用这些知识,让我们做一个抽签游戏吧。
从 0 到 4 的 5 个值中抽取一个,将其与运势对应。
| 数字 | 运势 |
|---|---|
| 4 | 大吉 |
| 3 | 吉 |
| 2 | 中吉 |
| 1 | 凶 |
| 0 | 大凶 |
那么,程序会变成什么样呢?以下是示例答案:
package main
import (
"fmt"
"math/rand/v2"
)
func main() {
switch rand.N(5) {
case 4:
fmt.Println("大吉")
case 3:
fmt.Println("吉")
case 2:
fmt.Println("中吉")
case 1:
fmt.Println("凶")
case 0:
fmt.Println("大凶")
}
}
改变概率
上面的程序,从大吉到大凶,出现概率都是一样的,但这不太像抽签。我们来根据运势,改变出现的概率。做法有很多,这次我们简单地将一个运势,对应多个数字。
| 数字 | 运势 |
|---|---|
| 9 | 大吉 |
| 8, 7, 6 | 吉 |
| 5, 4, 3 | 中吉 |
| 2, 1 | 凶 |
| 0 | 大凶 |
这样做,“吉”的出现概率将会是“大吉”的3倍。那么,写成程序会是怎样呢?
package main
import (
"fmt"
"math/rand/v2"
)
func main() {
switch rand.N(10) {
case 9:
fmt.Println("大吉")
case 8, 7, 6:
fmt.Println("吉")
case 5, 4, 3:
fmt.Println("中吉")
case 2, 1:
fmt.Println("凶")
case 0:
fmt.Println("大凶")
}
}
将多个 case 汇总在一起,可以写得更简洁。
本章总结
- if △△ { 〇〇 } else { ×× }` 这个句子的意思,是“如果△△的布尔值为真,则执行〇〇;如果为假,则执行××。”
switch在需要区分多个条件时非常方便。根据switch后面的值与哪个case相等,选择执行对应的分支。
第七章 做猜数字游戏(for循环)
上次我们学习了条件分支,踏进编程门槛了。这次我们将学习如何重复处理同一件事。
7.1 重复
使用 for 可以编写重复(循环)处理。Go 的 for 语句有以下三种形式。
其他语言中的 while 和 foreach 的功能,也集中在 for 中。
最重要的,是记住的第一和第二条。第三点,目前只需在脑海的角落里留个印象。
for 条件 { ... }- 满足条件的情况下循环处理。相当于其他语言的while。for range 値 { ... }- 按顺序提取数组或切片等并循环。相当于其他语言中的foreach。for 初始化; 循环条件; 循环后处理 { ... }- 按“初期化、条件、循环后处理”的流程进行处理。
7.1.1 只有一个条件语句的 for 语句
首先介绍的,是在满足条件时重复的 for 语句。相当于其他语言中的 while 。
package main
import (
"fmt"
"math/rand/v2"
)
func main() {
for rand.N(2) == 1 {
fmt.Println("循环")
}
fmt.Println("结束")
}
$ go run .
循环
循环
循环
结束
如果 rand.N(2) 的返回值为 1,则执行 {} 的内容。
执行后再次执行 rand.N(2) ,检查返回值是否为 1,如果是,则执行 {} 的内容。
这样循环重复。如果不再满足条件,则退出 {} 并显示 结束 。运气好的话,此语句会循环很多次,但也可能直接结束。
这样在满足条件的情况下,重复执行 {} 里面的内容的语句就是 for 语句。
条件可以省略不写。在这种情况下,将始终被视为满足条件,形成所谓的“无限循环”。以下代码会输出大量文本,请准备好随时按“Ctrl+C”强制结束程序。
package main
import "fmt"
func main() {
for {
fmt.Println("无限循环")
}
}
7.1.2 用 range 指定循环范围
接下来介绍的是使用 range (范围)的 for 循环。
range 中还有不同的用法,这些将在后面介绍。本章节中首先介绍的是**“指定次数重复”的 range 用法**。
请看代码:
package main
import "fmt"
func main() {
for range 5 { // 执行5次
fmt.Println("循环")
}
fmt.Println("结束")
}
package main
import "fmt"
func main() {
var i = 0
for i = range 5 { // range 前面用等号的例子
fmt.Println(i)
}
fmt.Println("循环结束后会执行这里")
}
$ go run .
循环
循环
循环
循环
循环
结束
通过在 range 后面写上重复次数,就可以重复执行 {} 中的处理。
此外,您还可以将“重复几次”放入变量中使用。这可以通过在 range 前面写 := 或 = 语句来实现。
package main
import "fmt"
func main() {
for i := range 5 { // 用变量 i 当次数
fmt.Println(i)
}
fmt.Println("循环结束后会执行这里")
}
$ go run .
0
1
2
3
4
循环结束后会执行这里
请注意,此处 i 的起始值是 0 。
变量名可以随意起,但表示循环次数的值,通常会使用字母 i 。这个习惯,据说源自编程语言“APL”中使用的希腊字母 ι(伊奥塔)。
7.1.3 初期化・循环条件・循环后处理的用法
最后介绍的,是最经典的 for 循环用法。很多人可能在 C 语言中熟悉了这种写法。我们来写一段代码,像在 range 中那样重复执行 5 次。
package main
import "fmt"
func main() {
for i := 0; i < 5; i++ {
fmt.Println(i)
}
}
$ go run .
0
1
2
3
4
以 初期化语句; 循环条件; 循环后处理 的形式,用英文分号 ; 分隔三个表达式。
这种情况下,这个 for 语句的意思是:
- 将变量
i初始化为 0, i小于 5 的时候重复循环- 每次循环将
i加 1(++是表示将 i 加 1 的符号)
如您所见,同样的功能,使用 range 可以更写得更短,感觉不会有人故意写得这么啰嗦。
但是,这个用 range 指定次数循环的功能,是最近才加入的。在此之前我们一直使用这种经典写法。为了阅读老代码,有必要了解这个语法。
7.2 循环控制
for 循环,可以在中途停止或跳过。
读到这里,感觉编程用的关键词变多了呢。
虽然可能会让你感到有些吃力,但这些关键词,并不需要一记住。
只需要在实践中多写几次,逐渐掌握即可!
7.2.1 break 中断
使用关键词 break ,可以在中途停止循环。
package main
import "fmt"
func main() {
for i := range 5 {
if i == 2 {
break
}
fmt.Println(i)
}
fmt.Println("循环后执行次语句")
}
$ go run .
0
1
循环后执行次语句
可以看到,range 指定执行5 次重复的循环,满足 if i == 2 这个条件时执行了 break ,在途中被中断。
7.2.2 continue 继续
使用 continue 语句,可以跳过本次循环的其余部分,继续下一次循环。
package main
import "fmt"
func main() {
for i := range 5 {
if i == 2 {
fmt.Println("跳过此次循环")
continue
}
fmt.Println(i) // 如果上面的continue执行了,这次将不会执行这一句
}
}
$ go run .
0
1
跳过此次循环
3
4
循环本身虽然被没有中断,但 continue 时,本该执行的 fmt.Println(i) 却在该次循环中被跳过。
您能理解发生了什么吗?
[面向有经验的人] 多重循环和带标签的 break、continue
break 和 continue ,通常只能中断或跳过最内层的循环。在多重嵌套的情况下,如果想要中断或继续外层循环,就需要与标签结合使用循环语句。
package main
import "fmt"
func main() {
loop: // "loop"标签
for i := range 3 {
for j := range 3 {
if i == 2 {
break loop // 指定标签,跳出特定循环的 break 语句
}
}
}
}
行头中写的 loop: ,可以为接下来的句子(在这种情况下是 for i := ... )添加标签。虽然 loop: 的额外缩进,可能会让你感到不适,但请适应。
学习 break 和 continue 等关键字时,作者本人也曾经觉得非常吃力。但在不断查阅资料与使用的过程中,逐渐就习惯了,所以不要着急,逐步推进吧。
7.3 输入
赶快用所学的知识,做一个猜数字游戏吧……
虽然我想这么说,但如果程序无法接收玩家输入,就没法做游戏。接受来自终端的输入的函数,只在本章用一次,所以我的介绍会比较简略。
在终端接收输入,可以使用 fmt.Scanln 。将变量的指针作为参数传给 fmt.Scanln , fmt.Scanln 就会将用户输入的值,赋值给该变量。
至于指针是什么,稍后会解释。
package main
import "fmt"
func main() {
fmt.Println("请输入数字")
input := 0
fmt.Scanln(&input) //加个 & ,将 input的指针传给fmt.Scanln
fmt.Println("你输入的值是", input)
}
$ go run .
请输入数字
(在这里输入数值,然后按回车键结束)
你输入的值是 42
7.4 制作猜数字游戏
前提知识准备好了。接下来我们制作一个猜数字游戏吧。
用程序生成一个随机的秘密值,玩家需要猜出这个值。如果猜错了,就继续猜,直到猜对为止。
同时作为提示,程序会显示“答案你比猜的数更大”或“答案你比猜的数更小”。
package main
import (
"fmt"
"math/rand/v2"
)
func main() {
answer := rand.N(100) // 生成 0 到 99 的数字
for {
fmt.Println("请输入数字")
input := 0
fmt.Scanln(&input)
if input == answer {
fmt.Println("答对了!")
break
} else if input < answer {
fmt.Println("答案你比猜的数更大")
} else {
fmt.Println("答案你比猜的数更小")
}
}
fmt.Println("游戏结束")
}
$ go run .
请输入数字
50
答案你比猜的数更大
请输入数字
75
答案你比猜的数更大
请输入数字
87
答案你比猜的数更小
请输入数字
81
答案你比猜的数更大
请输入数字
84
答案你比猜的数更大
请输入数字
86
答案你比猜的数更小
请输入数字
85
答对了!
游戏结束
综合运用学到的知识,我们已经能够制作猜数字游戏了。
能够使用 if 和 for 做东西,已经可以称得上是合格的程序员了。
如果心有余力,可以设定猜测次数限制,或统计玩家猜了几次,尝试各种玩法。
此外,这次做的是一个非常简单的数字猜测游戏,但不要小看这个猜数字游戏。
结合接下来将要说明的“数组・切片”结功能,这个游戏可以变成真正的推理游戏,就像“Hit and Blow”那样。
真正的游戏所需的精髓,你已经掌握不少了。
本章总结
- 使用
for可以编写循环处理。 for 条件 { ... }在条件满足的情况下重复循环。for range 回数 { ... }固定次数的循环。break,continue可以控制循环流程。
第八章 绘制窗口
在终端里运行的纯文字程序,也差不多让人厌了。这次终于要显示画面了。
8.1 掌握 Ebitengine 以前
接下来,我想立即使用 Ebitengine……
可是,我还没有教完使用 Ebitengine 所需的前提知识。 于是,我们先使用作者精心制作的 miniten 包 (精简版Ebitengine),学习制作一款游戏。
这是一个弹出窗口,并显示“Hello, World!”的程序。
package main
import "github.com/eihigh/miniten"
func main() {
miniten.Run(draw)
}
func draw() {
miniten.Println("Hello, World!")
}
那么,我们马上来运行它吧。
$ go run .
main.go:3:8: no required module provides package github.com/eihigh/miniten; to add it:
go get github.com/eihigh/miniten
哦...出错了。
当使用像 miniten 这样的新的外部包时,如果该包信息未记录在 go.mod 文件中,就会出现错误。
请执行 go mod tidy 命令,以更新 go.mod 文件。
$ go mod tidy
...应该会看到一些log...
稍等一下,等命令执行完后,再次运行程序吧。
第一次运行,可能会多花一点时间。
$ go run .
如下图,看到窗口左上角显示“Hello, World!”就可以了!🎉

有其他语言经验的人,Go 从互联网上的下载第三方包的步骤,可能简单到令人感动。
作者第一次看到的时候,确实也很感动。
8.2 绘制图形
那么就按照现在的节奏,继续学习画矩形和圆吧。
绘制必须在 draw 函数里面进行(稍后会解释理由)。
func main() {
miniten.Run(draw)
}
func draw() {
miniten.Println("Hello, World!")
miniten.DrawRect(0, 50, 100, 100) // 四个数,分别是矩形左上角坐标(0、50)、宽(100)、高(100)
miniten.DrawCircle(200, 200, 50) // 中心点的坐标(200、200)、圆的半径(50)
}

miniten 可以绘制的图形,只有矩形和圆形。
花其他形状或换颜色,要等到真正入门 Ebitengine 后再学习。
8.3 座标
这里所说的坐标,是“代表窗口内某点”的值。
由表示横向距离的“X”,和纵向距离的“Y”的成对表示。
- 窗口左上角是起点,此处的X 为
0,Y 为0。 - 从左上角向右移动,X 增加;向下移动,Y 增加。
- 刚才绘制的矩形,左上角坐标是
(0, 50),也就是横向在窗口最左边,纵向是在窗口顶部下移 50 的位置。 - 刚才绘制的矩形,宽度和高度分别为
100,因此矩形右下角的坐标是(0+100, 50+100) = (100, 150)。
学校里学习的坐标轴,往上走时 Y 值通常会增加。
但游戏窗口的坐标,往下走的时候 Y 值增加,与学校里常见的相反。
就是这么规定的,请习惯。
8.4 绘制图像
在 miniten 中,可以绘制的图形只有圆和矩形,可能会让你感到有些无聊。
不过请放心,在这里。您也可以加载使用喜欢的图片!
首先需要准备图像文件。什么图都可以,但是为了懒得准备图像的你,这里也准备了 Go 吉祥物 gopher 君的图片。

该设计,采用创意共享 4.0 署名许可。
阅读本文以获取更多详细信息:https://blog.golang.org/gopher
请右键点击这个 gopher 君,选择“另存为图片”。并将保存的图片文件,移动到与程序相同的目录中。
这是绘制图像的程序。请根据你保存的图像文件名,调整 "gopher.png" 的文件名部分。同时也不要忘记添加正确的扩展名。
func main() {
miniten.Run(draw)
}
func draw() {
miniten.DrawImage("gopher.png", 0, 0) // 图片文件名、左上角图标
}
如果图片显示在左上角,那就成功了!🎉

如果无法正常显示,请检查一下图片文件名是否正确、是否忘记加扩展名,以及是否放在与程序相同的目录中。
本章总结
- 在 miniten 中,绘图的处理,需要写在 draw 函数里面。
- 绘制图像时,请将图像文件放在同一目录。同时不要忘记确认扩展名。
- 表示窗口某点位置的数字,被称为坐标。
第九章 移动角色(帧和相关概念)
这次我们将学习游戏编程不可或缺的“帧”,顺便也要介绍“全局变量”。
9.1 让 gopher 君跳起来
我想让 gopher 君(关于 gopher 君的图片来源,请参见上一章)跳起来,写了一个“在鼠标点击时 gopher 君向上,松开时向下移动”的程序。
如果你觉得这个行为不太像跳跃,请期待下一章。
package main
import "github.com/eihigh/miniten"
var y = 0
func main() {
miniten.Run(draw)
}
func draw() {
if miniten.IsClicked() {
y -= 3
} else {
y += 3
}
miniten.DrawImage("gopher.png", 0, y)
}
顺便提一下,下面的程序, gopher 君无法运动。
package main
import "github.com/eihigh/miniten"
func main() {
miniten.Run(draw)
}
func draw() {
y := 0 // 就是这里
if miniten.IsClicked() {
y -= 3
} else {
y += 3
}
miniten.DrawImage("gopher.png", 0, y)
}
要了解两者的区别,首先需要理解什么是“帧”。
9.2 随处可见的视频,其实是翻页漫画
所有的视频,都是高速切换的静止图像的集合。
小时候闲着没事,在笔记本角落绘制的范爷漫画,快速翻页时看起来像在运动。
所谓的视频,原理与翻页漫画完全相同。视频中,一张张静止图像被称为帧,单位时间内的帧的数量称为帧率。帧率的单位是 FPS(每秒帧数)。帧率越高,视频看起来越流畅。
游戏画面看起来在动,原理也是一样的。令人惊讶的是,大多数游戏**每次切换帧时,都会从零开始重新绘制屏幕上的所有内容。**如果“帧率”是 60FPS,那么每帧的绘制时间仅为 1/60 秒 = 0.016 秒。如果无法在时间内完成绘制,就会导致画面闪烁,动作卡顿,这就是所谓的“掉帧”。
游戏图形学,基本上就是研究如何在帧的限制时间内,尽可能多绘制的学问。大家在玩游戏时,可以联想一下这个知识。
9.3 draw(绘图)函数

miniten 里面,draw 函数在每一帧会被调用一次。
每次切换新的帧时,游戏引擎会准备一个全新的空白帧,并调用 draw 函数进行绘制。
然后,在切换到下一帧时,前一帧的内容会被丢弃,新的帧画面会被渲染,就这么循环往复。
draw 函数,负责在限制时间内完成帧的绘制,因此写在 draw 函数中的处理不应耗时过长。这会导致掉帧和帧率下降。
要感受帧的速度,可以在 draw 函数中执行 fmt 的 fmt.Println 。这个程序,会在每一帧中在终端显示“帧”。
下面的程序。可以看到“帧”以相当快的速度不断显示。
package main
import (
"fmt"
"github.com/eihigh/miniten"
)
func main() {
miniten.Run(draw)
}
func draw() {
fmt.Println("帧")
}
$ go run .
帧
帧
帧
帧
帧
帧
miniten.Run(draw) 会在窗口关闭前逐帧调用 draw 函数。当窗口关闭时, miniten.Run(draw) 被结束,draw 函数不再被调用。
与draw函数相反,main 函数每次程序启动时只执行一次。
以下程序在启动时会显示一次“启动”,关闭窗口,并结束 miniten.Run(draw) 时会显示一次“结束”。
package main
import (
"fmt"
"github.com/eihigh/miniten"
)
func main() {
fmt.Println("启动")
miniten.Run(draw)
fmt.Println("结束")
}
func draw() {
}
超高速地绘制帧,是游戏编程的重要特征。其他领域的程序员,意外地有很多人并不知道这一点,所以请务必记住。
9.4 全局变量
还有一个没有解释的内容,就是在函数外部声明的全局变量。
和函数中的变量(与全局变量相对,叫局部变量)一样,全局变量的声明和赋值,可以同时进行。
不过,由于语法上的限制,全局变量无法使用 := 符号,需要使用 var 关键字声明。
全局变量的声明・赋值・使用规则:
package main
import "fmt"
var x = 42 // 全局变量需要使用 var 这个关键字来声明
func main() {
fmt.Println(x) // 42
}
局部变量在作用域(如函数等)结束时会消失。
全局变量从程序启动到结束,会一直存在。
以下的程序,在程序启动时将 x 赋值为 0,并在每一帧中加 1。由于 x 不会消失,因此会持续加 1。
结果就是程序绘制的 gopher 君的整体坐标向右移,看起来gopher 君就像正在向右移动。
package main
import "github.com/eihigh/miniten"
var x = 0 // 启动时赋值为 0
func main() {
miniten.Run(draw)
}
func draw() {
x += 1 // 帧数加 1
miniten.DrawImage("gopher.png", x, 0)
}
相反,以下的程序中, x 在函数内部声明,因此每个帧里的 x 会消失。
然后进入另一帧的时候, x 会从 0 重新开始。这种情况下,gopher 君无法移动。
package main
import "github.com/eihigh/miniten"
func main() {
miniten.Run(draw)
}
func draw() {
x := 0
x += 1
miniten.DrawImage("gopher.png", x, 0) // x一直是1
// draw函数执行完以后,x 也会失效
}
补充内容:draw 函数的调用频率
在 miniten 中调用 draw 函数(以及在 Ebitengine 中的 Draw 函数)的频率,实际上是由你使用的显示器决定。
显示器每秒更新屏幕的次数称为刷新率,单位是 Hz(赫兹)。大多数显示器的刷新率为 60Hz,也就是说每秒更新 60 次屏幕,近年来所谓的电竞显示器,也有达到 120Hz 或 144Hz 等的高刷新率产品。
游戏生成帧的速度,如果与显示器更新频率不同,会导致前后帧的内容,混在一起显示在屏幕上,这被称为画面撕裂。
为了防止撕裂,有一种叫做“垂直同步”的技术,会根据屏幕的刷新率生成帧。miniten(以及Ebitengine)默认启用垂直同步,因此会根据刷新率生成帧。因此,如果使用高刷新率显示器,draw 函数也会被更频繁地调用。
这个做法,在防止撕裂方面很有用,但由于显示器的处理速度不同,会导致额外的问题。上述的gopher 君,在高刷新率屏幕里下移动的速度会更快。
这样一来,会导致做游戏变困难。因此, Ebitengine 中有一个固定每秒调用 60 次的 Update 函数,它和 Draw 函数不一样,不依赖于屏幕刷新率。
具体细节将在后面的文章中提到,正是因为这个设计,使得游戏的制作更容易,同时也防止了画面撕裂现象。
本章总结
- 帧是构成视频的一张又一张静止图像。
- 帧率 (FPS) ,指每秒可以显示多少帧。
- draw 函数在每一帧中被调用。
- 全局变量,在程序启动到结束的期间内,一直有效。
第十章 让角色跳得自然(浮点数和类型)
上次我们学习了如何接收输入。这次我们将学习如何让角色跳得更自然。
10.1 让角色跳跃起来
以下是一个使角色(自然)跳跃的程序示例。
执行时,点击时 gopher 君会向上跳,松开时会向下落。
package main
import "github.com/eihigh/miniten"
var (
y = 0.0
vy = 0.0 // Velocity of y (Y方向速度) 的略称
g = 0.1 // Gravity (重力加速度) 的略称
)
func main() {
miniten.Run(draw)
}
func draw() {
if miniten.IsClicked() {
vy = -3
}
vy += g // 当前速度 = 速度+加速度
y += vy // 当前位置 = 位置+速度
miniten.DrawImage("gopher.png", 0, int(y))
}
10.2 速度与加速度
物体下落时,物体会朝着地面加速。在游戏中,加速通常通过每帧逐渐增加速度来表现。
上一章,是通过每帧增加或减少一个固定值 (3) 来表示恒速运动,
这次,通过每帧逐渐增加的速度 vy 反映到位置 y 上,从而实现加速度效果。
跳跃
此程序通过在点击期间将速度设置为 -3 (向上 3)来表现跳跃。
现实中的跳跃在离开地面前的瞬间,速度会达到向上的最高速,之后由于重力的作用,向上速度会逐渐减少。
但为了简单起见,这里在点击期间一直给予向上的固定速度。虽然是为了容易做,但这在游戏中的表现,看起来像是**“按得越久跳得越高”**,手感类似超级马里奥的跳跃,是一个意外好用的技巧。
10.3 数据类型与类型转换
此处用到的另一个新知识是类型转换。 就是int(y) 的部分。
10.3.1 浮点数
变量 y vy g 在声明时赋值为类似 0.0 的带小数点的数值,因此这些变量的类型是 float64 的浮点数/floating-point number 类型。浮点数与整数类型不同,可以表示小数点以下的数值。
var i = 0 // int 类型的数字
var f = 0.0 // float64 类型的数字
浮点数这个独特的名字,实际上是在提醒人们注意误差问题(比如3D 游戏中的墙穿墙 bug ,很多问题都可以来自于浮点数),所以故意用这样一个严肃的名字来代替“实数”。
为什么用“浮动小数点数”而不是“小数”?
这是因为在计算机中表示小数的方法是“移动小数点”。例如 0.5 是将 5 下移 1 位, 0.05 是将 5 下移 2 位。
这种方法,可以表示包含小数点的非常广泛的数值,但因为计算机只能存储存有限位数,小数计算总是伴随着误差。例如 0.1 + 0.2 会变成 0.30000000000000004 。
编程中,浮点数应该避免使用等值比较( == )运算符。
如果浮点数误差成为问题,则需要采取如用取整后计算等对策。
10.3.2 类型转换
在 Go 中,不同类型之间的计算和赋值基本上被禁止。 miniten.DrawImage 函数接受 int 类型的坐标,所以不能直接传递 float64 类型的 y 。
不同类型的赋值,需要像 int(y) 这样,以 类型名称(变量名) 的形式进行转换。 float64 类型到 int 类型的转换,会将小数点以下部分舍去,变为整数。
a := 1
b := 2.5
c := a + b // 不能这么做
c := a + int(b) // 可以这么做(c 等于3)
d := float64(a) + b // 可以这么做(d 会变成 3.5)
vy = -3 难道不是不同类型(浮点数 与 整数)之间的计算吗?
答案是,Go 对原始值(字面量)之间的计算,会在类型上进行隐含的特殊处理,可以在没有显式类型转换的情况下,完成转换与计算。
10.3.3 数据类型
int 和 float64 等,所有变量都有称为类型/type 的数据类型。类型一旦声明后就无法再更改。类型作为“约束”起作用,大多数情况下,禁止不同类型之间的计算和赋值。这样可以防止“计算结果不明确”或“结果出错”。
在 Go 中的常用类型如下。
var i int // 整数
var f float64 // 64bit浮点数
var s string // 字符串
var b bool // 布尔值
var e error // 错误类型
各种类型,对应以下的“字面量”(指在程序中直接出现,且无法更改的值)。
i := 1 // 整数
f := 1.0 // 64bit浮点数
s := "Hello" // 字符串
b := true // 布尔值
您可以基于这些类型,自己声明新类型。这种用法,用到的时候再说吧。
10.3.4 其他类型
Go 还具有许多其他类型。
首先,整数和浮点数,有不同大小的细分类型。不同的类型,有的占用空间小,有的误差小,是编程高手用的高级类型。
| 类型名 | 大小 | 用途 |
|---|---|---|
| int8 | 8bit | -128 ~ 127 的整数 |
| int16 | 16bit | 32768 ~ 32767 的整数 |
| int32 | 32bit | -2147483648 ~ 2147483647 的整数 |
| int64 | 64bit | -9223372036854775808 ~ 9223372036854775807 的整数 |
| float32 | 32bit | 32bit浮点数 |
此外,还有表示大于等于 0 的无符号整数类型 uint 。它也有不同大小,属于细分类型。
| 类型名 | 大小 | 用途 |
|---|---|---|
| uint8 | 8bit | 0 ~ 255 的整数 |
| uint16 | 16bit | 0 ~ 65535 的整数 |
| uint32 | 32bit | 0 ~ 4294967295 的整数 |
| uint64 | 64bit | 0 ~ 18446744073709551615 的整数 |
此外, int 和 uint 的大小在 32 位环境下为 32 位,在 64 位环境下为 64 位。现代计算机几乎都是 64 位环境。
其他类型大致总结如下。复数类型很小众,没人见过有人在用。在游戏中似乎有用,但实际上并没有……。
| 型名 | 用途 |
|---|---|
| byte | uint8 的别名。用于表示字节数据。 |
| rune | int32 的别名。用于表示 Unicode 的代码点。 |
| any | 任意类型。 interface{} 的别名 |
| uintptr | 用于表示指针 |
| complex64 | 复数。表示实部和虚部的两个 32 位浮动小数点数的组合 |
| complex128 | 复数。表示实部和虚部的两个 64 位浮动小数点数的组合 |
10.3.5 默认值
Go 的变量,如果在声明时没有初始化,将会被赋予默认值。
根据类型的不同,默认值也不一样,具体如下:
var i int // 0
var f float64 // 0.0
var s string // ""
var b bool // false
作者经常搞不清布尔值的默认值到底是什么?是 false。
本章总结
- 要使角色自然地跳跃,需要用到速度和加速度。
- 浮动小数点数可以表示小数点以下的数值。
- 不同类型之间的计算和赋值基本上被禁止。需要进行显式的类型转换。
第十一章 加快动作(切片与循环)
上次我们学习了如何让角色自然地移动。然而,游戏中除了玩家角色,还需要许多敌人和物体。因此,我们需要一种管理多个对象的方法。
11.1 数组
当我想移动很多东西时
var x0 int
var x1 int
var x2 int
写很多变量很麻烦。几个变量还好,几千、几万个,手动写下来就不太可能了。这时一般使用数组/array……
但在 Go 中更常用的是切片/slice,不过为了方便说明,我先从数组开始讲解。
数组是将任意数量的相同类型的变量排列在一起的结构。数组的每一个变量称为元素/element。数组的类型以 [长度]元素类型 的形式声明。例如
var xs [3]int
如果这样写,就像刚才写的 x0 x1 x2 一样,程序中会造出来三个放在一起的 int。
配列和切片,这种拥有多个相同元素的变量,英文名的后缀,通常会加上表示英语复数形式的 s 和 es 。比如 xs 是 x 的复数形式。
配列也可以在声明的同时赋值。表示数组的值,可以使用合成字面量/composite literal,以 类型名称{值, 值, ...} 的形式书写。
var xs = [3]int{0, 100, 200}
// 上面的写法,与下面作用一样
var x0 = 0
var x1 = 100
var x2 = 200
配列的魅力,无疑在于索引/index。索引是元素编号、是从 0 开始的数字。这样,您可以通过 变量名[索引] 来访问每个元素。
func main() {
xs := [3]int{0, 100, 200}
fmt.Println(xs[0]) // 索引为 0 的元素,存储的数字是 0
fmt.Println(xs[1]) // 索引为 1 的元素,存储的数字是 100
}
索引是数字,因此可以写算式,也可以使用变量。连续定义多个变量,就没有这种方便的功能了。
func main() {
xs := [3]int{0, 100, 200}
index := 1
xs[index] = 99 // 用变量当索引
fmt.Println(xs) // [0 99 200]
}
请注意,如果索引超过数组的长度或为负数,程序会崩溃,因此请小心。
func main() {
xs := [3]int{0, 100, 200}
fmt.Println(xs[3]) // 程序崩溃
fmt.Println(xs[-1]) // 程序崩溃
}
11.2 for range循环的另一种用法
配列和切片与 for range 语句结合使用时会更方便。
之前教的是“指定重复次数,并获取当前次数”的 for range 语句。
这次新学的是“顺序提取数组或切片的元素”的 for range 语句。
func main() {
xs := [3]int{0, 100, 200}
for i, v := range xs {
fmt.Println(i, v)
}
}
$ go run .
0 0
1 100
2 200
这个语法与指定回数的 for range 非常相似,但重复的次数是数组元素的数量,并且可以与循环次数 i 一起提取对应的元素 v 。这种类型的 for 语句非常常用。
如果不需要回数,只想提取元素,可以使用特殊的空白变量 _ 来明确表示不用并丢弃这个参数。
在 Go 中,如果定义了却没有使用某个变量,会导致程序报错,因此需要记住这一点。
func main() {
xs := [3]int{0, 100, 200}
for _, v := range xs {
fmt.Println(v)
}
}
相反,如果只想提取次数,则只需使用一个变量来接收。
语法上就是这么规定的,习惯并记住就好。
func main() {
xs := [3]int{0, 100, 200}
for i := range xs { // 这里没有定义 v,只接收循环次数
fmt.Println(i)
}
}
移动多个箱子
接下来,我们结合数组和 for,试着移动多个箱子。
package main
import "github.com/eihigh/miniten"
var xs = [3]int{0, 100, 200} // 箱子的初始位置
func main() {
miniten.Run(draw)
}
func draw() {
for i := range xs { // 重复三次
xs[i] += 1 // 向右移动箱子
}
for _, x := range xs { // 重复三次
miniten.DrawRect(x, 0, 50, 50) // 描绘箱子
}
}
这个程序将显示三个逐渐向右移动的箱子。
需要注意的地方可能是 xs[i] += 1 。以下的写法是错的。
func draw() {
for _, x := range xs {
x += 1
}
for _, x := range xs {
miniten.DrawRect(x, 0, 50, 50)
}
}
for _, x := range xs 取出的 x 是元素克隆后的副本,因此即使对副本加上 x += 1 ,也不会影响原始数组 xs 。需要以 xs[i] += 1 的形式对原始数组进行赋值。这有点难,所以在今后巩固 Go 的基础时,逐渐理解就可以了。
11.3 切片
配列的类型 [长度] 部分是类型的一部分,此数字相当特殊,不能在这里使用变量。
var length = 3
var xs = [length]int{0, 100, 200} // できない
此外,数组的长度是固定的,无法在后期扩展。实际上,Go 的数组并不是为了日常使用而设计的,大多数情况下,使用的是可以在后期扩展长度的切片/slice。
切片以 []元素类型 的形式书写。其他部分基本与数组相同。
用切片重写刚才移动箱子的程序,可以看出,除了类型外完全相同,能够正常运行。
package main
import "github.com/eihigh/miniten"
var xs = []int{0, 100, 200} // 把[3] 替换成 [] ,这里用的就不是数组,而是是切片了
func main() {
miniten.Run(draw)
}
func draw() {
for i := range xs {
xs[i] += 1
}
for _, x := range xs {
miniten.DrawRect(x, 0, 50, 50)
}
}
append 函数
之前说的内容中提到,切片与数组不同,声明以后可以继续添加元素。使用 append 函数,可以在切片的末尾添加元素。
func main() {
xs := []int{} // 做一个空切片
xs = append(xs, 10) // 添加一个元素
xs = append(xs, 20, 30) // 添加多个个元素
fmt.Println(xs) // [10 20 30]
}
注意点是, append 函数会返回一个新的切片作为返回值,因此需要将其赋值给原始切片。
xs = append(xs, 0) // OK
append(xs, 0) // NG(不会改变原始切片的值)
len 函数
当您想知道该切片的当前长度时,可以使用 len 函数。
func main() {
xs := []int{0, 100, 200}
fmt.Println(len(xs)) // 3
xs = append(xs, 300)
fmt.Println(len(xs)) // 4
}
玩玩看
该程序在点击期间添加 gopher。
package main
import "github.com/eihigh/miniten"
var xs = []int{} // 初始状态下是空的,一个也没有
func main() {
miniten.Run(draw)
}
func draw() {
if miniten.IsClicked() {
xs = append(xs, 0) // 每点击一次,就加一只地鼠
}
for i := range xs { // 循环“地鼠的数量”回
xs[i] += 1
}
for _, x := range xs { // 循环“地鼠的数量”回
miniten.DrawImage("gopher.png", x, 0)
}
miniten.Println(len(xs)) // 显示有几只地鼠
}
gopher大増殖
slices 包
追加以外,在 slices 包里面还有删除和排序等功能。虽然这是 go 的官方标准包,但意外地不为人知。在这里知道了这件事,就能凭知识与他人拉开差距。
[面向有经验的人] 切片与数组的关系
切片的实体顾名思义,就是像切片奶酪一样,切割出来的数组的一部分或全部。在索引部分写上 [开始:结束] ,可以切出数组或切片。开始和结束标志是可选的,省略时,分别表示开头和结尾。
a := [5]int{0, 1, 2, 3, 4}
s1 := a[:] // [0 1 2 3 4]
s2 := s1[:3] // [0 1 2]
s3 := s1[2:] // [2 3 4]
s4 := s1[1:4] // [1 2 3]
切片背后总是有一个数组。即使直接写 xs := []int{0, 1, 2} 来创建切片也是一样。背后会自动创建一个数组。
此外, append 函数在超出其背后数组的长度时,会重新创建一个更大的数组并复制元素。因此,如果知道切片的长度,使用 make 函数进行初始化,就可以避免切片的重新分配,从而提高程序运行速度。
// 虽然切片本体长度是0,但是后台因旱地创造了一个长度为100的数组
xs := make([]int, 0, 100)
后台数组的大小可以通过 cap 函数获取。观察 append 如何改变 cap 会很有趣。
func main() {
xs := []int{}
for range 100 {
xs = append(xs, 0)
fmt.Println(len(xs), cap(xs))
}
}
$ go run .
1 1
2 2
3 4
4 4
5 8
6 8
...中略...
97 128
98 128
99 128
100 128
在这一点上,几乎可以不必太在意,单纯地认为“切片很方便”使用也没有问题,但如果能够正确使用 make 函数,Go 的性能问题大约有 80% 可以解决(主观感想),所以掌握你会变得更强。
本章总结
这次内容很丰富,我们来总结一下好好复习吧。
- 切片将多个元素排列在一起。与数组不同,切片可以在后面添加元素
- 声明切片的语法,是
[]元素类型 - 可以写为
[]元素类型{元素, 元素, ...}的形式 - 取出元素的语法是 变量名[索引]`
- 使用
for 回数, 元素 := range 切片 { ... }这个语法,可以顺序提取切片的元素 append函数可以给切片添加元素len函数可以获取切片的长度- 可以使用 slices 包进行其他切片操作
第十二章 飞天地鼠游戏 (1)会动的土管
前一章我们学习了角色移动、管理数据等很多内容。这次我们终于要开始制作游戏了。
制作飞天地鼠游戏
这次制作的是 Ebitengine 的示例游戏中也包含的,flappy xxxx 游戏。游戏的内容是
- 点击后,gopher 君会跳跃。
- 右侧的有缺口的土管不断靠近,玩家要巧妙地穿过洞。
- 尽量长时间不碰壁、生存下去。
就是这么简单。让我们开始制作吧。
做跳跃的 gopher
首先,我们来创建玩家角色。这与之前写的程序几乎相同,但为了便于后续调整,我们将 X 坐标和跳跃力设置为变量。从现在开始,程序中的数字尽量使用变量。
package main
import (
"github.com/eihigh/miniten"
)
var (
x = 200.0
y = 150.0
vy = 0.0 // Y方向速度(Velocity of y)的缩写
g = 0.1 // 重力加速度(Gravity) 的略称
jump = -4.0 // 跳跃力
)
func main() {
miniten.Run(draw)
}
func draw() {
if miniten.IsClicked() {
vy = jump
}
vy += g // 当前速度 = 速度+加速度
y += vy // 当前位置 = 位置+速度
miniten.DrawImage("gopher.png", int(x), int(y))
}
迫近的土管
接下来我们将创建土管。作为土管的图像,我们准备了这个 wall.png ,请像 gopher 君时一样右键点击“另存为图片”,并将其放置在与程序相同的目录中。

让我们编写一个不断逼近的土管。重点是“每隔一定时间添加一堵土管”。
来自前一个程序的更改部分为绿色(TODO)。
在 Go 中,由多个英语单词组成的名称通常使用大写字母分隔,这被称为驼峰命名法。例如 wallXs 是 wall 和 xs 的合成。
package main
import (
"github.com/eihigh/miniten"
)
var (
x = 200.0
y = 150.0
vy = 0.0 // Y方向速度(Velocity of y)的缩写
g = 0.1 // 重力加速度(Gravity) 的缩写
jump = -4.0 // 跳跃力
frames = 0 // 经过的帧总数
interval = 120 // 土管的追加间隔
wallStartX = 640 // 土管的初始化x坐标
wallXs = []int{} // 土管的x坐标
wallWidth = 20 // 土管的宽度
wallHeight = 360 // 土管的高度
)
func main() {
miniten.Run(draw)
}
func draw() {
if miniten.IsClicked() {
vy = jump
}
vy += g // 新的当前速度 = 当前速度+加速度
y += vy // 新的当前位置 = 当前位置+速度
miniten.DrawImage("gopher.png", int(x), int(y))
// 从这里开始,写追加土管壁的代码
frames += 1
if frames%interval == 0 {
wallXs = append(wallXs, wallStartX)
}
// 追加土管壁的代码, 到这里就结束了
for i := range wallXs {
wallXs[i] -= 2 // 往左动
}
for _, wallX := range wallXs {
miniten.DrawImage("wall.png", wallX, 0)
}
}
起动后大约 2 秒钟,如果能看到土管从右向左逼近就可以了。

frames%interval == 0 的部分是关键。 % 是求余数的符号。余数的特点是会产生如下的周期性。
原始数字: 0 1 2 3 | 4 5 6 7 | 8 9 10 11 | 12 ...
除4的余数: 0 1 2 3 | 0 1 2 3 | 0 1 2 3 | 0 ...
这个性质非常适合“定期做某事”。 frames%120 == 0 意味着“每 120 帧做某事”。
draw 函数被调用的频率会因您使用的显示器而有所不同,因此请根据需要调整间隔和速度。
打孔
这次制作的土管从上到下完全封闭。我们来打个洞。打洞的方法有几种,这里我们就这样做。
-
上面的土管和下面的土管,分开考虑。
-
穴的上端 Y 坐标(
holeY)随机确定。 -
上面的土管,需要比
holeY高。 -
上面的土管的底部,与孔的顶部对齐。
-
上面的土管的顶部是
holeY - wallHeight。 -
下面的土管,将从
holeY向下移动一个孔的位置开始绘制。- 下的土管的顶部坐标是
holeY + holeHeight。
- 下的土管的顶部坐标是

package main
import (
"math/rand/v2"
"github.com/eihigh/miniten"
)
var (
x = 200.0
y = 150.0
vy = 0.0 // Y方向速度(Velocity of y)的缩写
g = 0.1 // 重力加速度(Gravity) 的缩写
jump = -4.0 // 跳跃力
frames = 0 // 经过的帧总数
interval = 120 // 土管的追加间隔
wallStartX = 640 // 土管的初始化x坐标
wallXs = []int{} // 土管的x坐标
wallWidth = 20 // 土管的宽度
wallHeight = 360 // 土管的高度
holeYs = []int{} // 洞的 Y 坐标
holeYMax = 150 // 洞的 Y 坐标的最大值
holeHeight = 170 // 洞的大小(高度)
)
func main() {
miniten.Run(draw)
}
func draw() {
if miniten.IsClicked() {
vy = jump
}
vy += g // 新的当前速度 = 当前速度+加速度
y += vy // 新的当前位置 = 当前位置+速度
miniten.DrawImage("gopher.png", int(x), int(y))
// 土管相关的处理
frames += 1
if frames%interval == 0 {
wallXs = append(wallXs, wallStartX)
holeYs = append(holeYs, rand.N(holeYMax))
}
// 土管相关的处理结束
for i := range wallXs {
wallXs[i] -= 2 // 往左动
}
for i := range wallXs {
// 描绘上方的土管
wallX := wallXs[i]
holeY := holeYs[i]
miniten.DrawImage("wall.png", wallX, holeY-wallHeight)
// 描绘下方的土管
miniten.DrawImage("wall.png", wallX, holeY+holeHeight)
}
}
显示背景
另外,我准备了一张表示天空的图片 sky.png 来营造氛围,也让它显示出来吧。

func draw() {
miniten.DrawImage("sky.png", 0, 0)
if miniten.IsClicked() {
vy = jump
}
vy += g // 新的当前速度 = 当前速度+加速度
y += vy // 新的当前位置 = 当前位置+速度
miniten.DrawImage("gopher.png", int(x), int(y))
// 以下略
最终,如果出现这样的土管与间隔,程序就完成了!

本章总结
这次我们创建了玩家和逼近的土管,基本上完成了游戏的外观。需要的变量数比以前多,但大部分是调整平衡游戏的固定数字,所以不用担心。下次我们将制作碰撞检测。
第十三章 飞天地鼠游戏 (2)碰撞检测
上次我们制作了游戏的外观。这次,我们将实现游戏的核心“与土管的碰撞检测”。
命中判定
现实世界中,只要有物体,就一定会能碰撞,但在游戏世界中,仅仅绘制物体并不会触发碰撞检测,而是会被直接忽略。游戏世界中,穿墙反而是自然规律。当你学习了碰撞检测后,希望你能体会到为世界上所有的物体添加碰撞检测的难度,并对开发者多一些理解和宽容。
碰撞检测的实现难度,会因为目标的形状而大大改变。这次做的“未经旋转的矩形之间的碰撞检测”,在碰撞检测领域算是相对简单的问题。关于其他形状的碰撞检测,请务必查看传说中的神网站“从玩游戏到做游戏(日文网站)”。
余谈:各种碰撞判定的难度排名
笔者主观地对 2D 游戏中的碰撞判定难度排了一下名。从上到下,由简单逐渐变难。
- 圆和点
- 圆和圆
- 未旋转的四边形(以下称 AABB)和点
- AABB 之间 ※此次讨论的内容
- 胶囊和圆形 ※这个非常好用
- 外凸的多角形之间
- 任意形状的多边形之间(难度高到可怕)
过于复杂的碰撞判定现在多由物理引擎处理,直接手写的机会不多。比起这个,学会“避免运动物体穿墙”的思路更为重要,所以高级内容有机会再进行探讨。
未被旋转的四角形之间的碰撞判定如图所示,把 X 和 Y 分开考虑之后,无论从哪边入手都可以判定重叠。

矩形的重叠,等效于“B 的最大值大于 A 的最小值,且 A 的最大值小于 B 的最小值”这个条件语句。
翻译成程序就是:
aMin < bMax && bMin < aMax
语句中的 && (两个&)表示“且”,当左右都为 true 时,这个式子的结果为 true。
排列组合 A 和 B 的位置来模拟,能够得出“这样判断没问题”的结论。
| 图 | aMin < bMax | bMin < aMax | 结果 |
|---|---|---|---|
![]() | true | true | true |
![]() | true | true | true |
![]() | true | false | false |
![]() | false | true | false |
接下来只需判断,是否同时在 X 和 Y 方向上都满足类似的条件即可。top 和 bottom ,分别意味着顶部和底部。
if (aLeft < bRight && bLeft < aRight) && (aTop < bBottom && bTop < aBottom) {
// 碰撞到了
}
为了将其落实到实际程序中,我们来整理一下逻辑。
- 矩形 A 是地鼠君。
- 矩形 B 是墙。
- 地鼠矩形的范围,是
从点(x, y) 到点 (x+gopherWidth, y+gopherHeight)。 - 上面的土管是
占据的范围是从(wallX, holeY-wallHeight) 到(wallX+wallWidth, holeY)。 - 下面的土管是
(wallX, holeY+holeHeight) から (wallX+wallWidth, holeY+holeHeight+wallHeight)。
事情变得相当棘手,但只要扎实地分步解开,就不难理解。
package main
import (
"math/rand/v2"
"github.com/eihigh/miniten"
)
var (
x = 200.0
y = 150.0
vy = 0.0 // Y方向速度(Velocity of y)的缩写
g = 0.1 // 重力加速度(Gravity) 的缩写
jump = -4.0 // 跳跃力
frames = 0 // 经过的帧总数
interval = 120 // 土管追加间隔
wallStartX = 640 // 土管的初始X坐标
wallXs = []int{} // 土管的X坐标
wallWidth = 20 // 土管的宽度
wallHeight = 360 // 土管的高度
holeYs = []int{} // 空洞的Y坐标
holeYMax = 150 // 空洞的Y坐标的最大值
holeHeight = 170 // 空洞的大小(高度)
gopherWidth = 60
gopherHeight = 75
)
func main() {
miniten.Run(draw)
}
func draw() {
miniten.DrawImage("sky.png", 0, 0)
if miniten.IsClicked() {
vy = jump
}
vy += g // 新的当前速度 = 当前速度+加速度
y += vy // 新的当前位置 = 当前位置+速度
miniten.DrawImage("gopher.png", int(x), int(y))
// 从这里开始,写追加土管的代码
frames += 1
if frames%interval == 0 {
wallXs = append(wallXs, wallStartX)
holeYs = append(holeYs, rand.N(holeYMax))
}
// 追加土管的代码,到这里就结束了
for i := range wallXs {
wallXs[i] -= 2 // 往左动
}
for i := range wallXs {
// 描绘上面的土管
wallX := wallXs[i]
holeY := holeYs[i]
miniten.DrawImage("wall.png", wallX, holeY-wallHeight)
// 描绘下面的土管
miniten.DrawImage("wall.png", wallX, holeY+holeHeight)
// 制作表示地鼠的矩形
aLeft := int(x)
aTop := int(y)
aRight := int(x) + gopherWidth
aBottom := int(y) + gopherHeight
// 定义表示上面土管的矩形
bLeft := wallX
bTop := holeY - wallHeight
bRight := wallX + wallWidth
bBottom := holeY
// 上面土管的判定
if aLeft < bRight &&
bLeft < aRight &&
aTop < bBottom &&
bTop < aBottom {
miniten.Println("碰到上面的土管了")
}
// 定义表示下面土管的矩形
bLeft = wallX
bTop = holeY + holeHeight
bRight = wallX + wallWidth
bBottom = holeY + holeHeight + wallHeight
// 地鼠与下面土管的碰撞判定
if aLeft < bRight &&
bLeft < aRight &&
aTop < bBottom &&
bTop < aBottom {
miniten.Println("碰到下面的土管了")
}
}
}
Go的书写格式有一套特定的规则,也规定了换行的位置。如果在编辑器中敲换行后引发错误,取消换行,遵循编辑器的建议是一个不错的选择。
地鼠接触土管的时候,如果屏幕左上角显示了消息,则表示程序运作正常。

补充
补充:更简单的方法 由于没有通用性,所以没有重点介绍。但这个游戏,可以通过判断“头部是否碰到上面的土管,脚是否碰到下面的土管”来更简单地判断。
// 碰撞判定
if wallX < int(x)+gopherWidth/2 && int(x)+gopherWidth/2 < wallX+wallWidth {
head := int(y) // 地鼠头部的位置
foot := int(y) + gopherHeight // 地鼠脚尖的位置
if head < upperWallHeight {
miniten.Println("碰到上面的土管了")
}
if lowerWallY < foot {
miniten.Println("碰到下面的土管了")
}
}
这个写法之所以成立,是因为这个游戏内的土管,必定与地板或天花板相连,否则会出现地鼠(的头部和脚部)走捷径穿透土管的现象。 尽管如此,这个方案,也用了将问题分解为 X 和 Y 两个方向,将问题化整为零的做法。仔细考虑一下,可能会很有意思。
床和天花板
顺便说一下,仅靠上述代码,如果地鼠逃到屏幕外的上面和下面,就可以永远避免触发碰撞判定。我们来给地板和天花板添加碰撞判定。做法比与土管的碰撞判定简单。目前的数值,可以允许稍微超出屏幕底部,但这部分可以根据个人喜好调整。
...前略...
// 底部的碰撞判定
if aLeft < bRight &&
bLeft < aRight &&
aTop < bBottom &&
bTop < aBottom {
miniten.Println("碰到底边了")
}
}
if y < 0 {
miniten.Println("碰到天花板")
}
if 360 < y {
miniten.Println("碰到底边了")
}
}
计分
这个游戏的分数,基于穿越的土管数量。虽然可以考虑其他做法,但是“数自己左侧的土管数量”的算法简单易懂,所以这次就用这个方法试试看。
func draw() {
miniten.DrawImage("sky.png", 0, 0)
score := 0
for i, wallX := range wallXs {
if wallX < int(x) {
score = i + 1
}
}
miniten.Println("Score", score)
...后略...
score是表示分数的变量。wallX < int(x)可以判断 gopher 的左侧是否有墙。gopher 左侧的土管的数量就是得分。- 从索引 0 开始逐个查看,满足条件后不断覆盖
score。 - 不满足条件,也就是说在 gopher 君右侧的墙不会覆盖
score。因此,最后满足条件(通过)的墙将成为得分。 - 切片索引从 0 开始,但为了计算“通过索引 0 的墙得 1 分,经过 1 的墙得 2 分”,我在索引上加了 1。
短短的代码中,充满了大量信息。这类使用循环的程序,考虑具体的值并逐一分析是非常重要的。
本章总结
这次实现了碰撞检测。下次将实现包含游戏结束的画面切换,整理游戏的结构并完成它。
第十四章 飞天地鼠游戏 (3)场景切换
前面的内容里,已经实现了判定和计分,游戏的核心部分完成了。这次,我们将构建被称为场景切换的外部功能。
场景切换
前回我们实现了碰撞判定,但显然还没有实现游戏结束的处理。此外,游戏启动后直接开始也不太友好,显得有些冷清。标题画面也是必要的。将屏幕切换为另一个状态的处理,被称为屏幕切换或场景切换。
设计画面
在实现画面迁移之前,我们先考虑一下需要哪些画面。这次我们将制作以下三个画面。
- 标题画面。点击后游戏开始。
- 游戏画面。撞到土管就会游戏结束。
- 游戏结束画面。显示分数,点击后返回标题画面。
考虑骨架结构
画面迁移虽然听起来复杂,但本质上只是一个 switch 语句。骨架大致是这样的,根据表示画面的变量 scene 来分支处理该帧的操作。 scene 是在满足下一个画面迁移条件时会被更改的东西。反过来说,在不满足迁移条件的情况下,始终保持相同的 scene 。
var scene = "title" // 保存当前画面的变量
func draw() {
switch scene {
case "title":
// 标题画面的处理
// 点击之后让 scene = "game"
case "game":
// 游戏画面的处理
// 撞上土管之后让 scene = "gameover"
case "gameover":
// 游戏结束画面的处理
// 点击之后让 scene = "title"
}
}
这样虽然简单,但如果将之前实现的 draw 函数的内容移到 case "game": 下面,就会变得非常难以阅读。
case "game":
// 计算得分
// 点击后跳跃
// 追加土管
// 移动土管
// 壁描绘土管
// 玩地鼠与土管的碰撞判定
// 天花板,地板的碰撞判定
// ...
在这种情况下,通过自己创建函数(处理的集合)来整理代码吧。使用函数时,switch 语句可能会是这样的。
func draw() {
switch scene {
case "title":
drawTitle()
case "game":
drawGame()
case "gameover":
drawGameover()
}
}
func drawTitle() {
// 标题画面的处理
}
func drawGame() {
// 前回までのdraw関数と同じ
// 计算得分
// 点画面跳跃
// ...
}
func drawGameover() {
// 游戏结束的处理
}
反映到实际的程序中:
package main
import (
"math/rand/v2"
"github.com/eihigh/miniten"
)
var (
x = 200.0
y = 150.0
vy = 0.0 // Y方向速度(Velocity of y)的缩写
g = 0.1 // 重力加速度(Gravity) 的缩写
jump = -4.0 // 跳跃力
frames = 0 // 经过的帧总数
interval = 120 // 墙的追加间隔
wallStartX = 640 // 墙的初始化x坐标
wallXs = []int{} // 墙的x坐标
wallWidth = 20 // 墙的宽度
wallHeight = 360 // 墙的高度
holeYs = []int{} // 空洞的y坐标
holeYMax = 150 // 空洞的y坐标的最大值
holeHeight = 170 // 空洞的大小(高度)
gopherWidth = 60
gopherHeight = 75
scene = "title"
)
func main() {
miniten.Run(draw)
}
func draw() {
switch scene {
case "title":
drawTitle()
case "game":
drawGame()
case "gameover":
drawGameover()
}
}
func drawTitle() {
}
func drawGame() {
// 和上一章的 drawGame() 函数完全一样,所以这里省略了代码
}
func drawGameover() {
}
执行此操作时, drawTitle 的处理为空,因此不会发生任何事情。让我们制作标题画面。
背景和“点击开始”的提示,同时显示 gopher 君。如果大家能跟到这里,理解这个应该没问题吧。
func drawTitle() {
miniten.DrawImage("sky.png", 0, 0)
miniten.Println("点击后开始")
miniten.DrawImage("gopher.png", int(x), int(y))
if miniten.IsClicked() {
scene = "game"
}
}
这下点击后就可以开始游戏了。接下来我们将制作游戏结束画面。
之前的代码,如果满足了碰撞判定的条件,只会在页面的左上角显示游戏结束的文字提示。
此次为了切换画面游戏结束画面, 会让scene = "gameover" ` ,显示专门的游戏结束画面。
在 drawGameover 函数中,显示游戏结束的消息和最后的分数。由于分数仍然是局部变量,因此无法在 drawGameover 函数中使用,所以需要将其改为全局变量。
将以上两个变更,反映到实际的程序中。
!削除部分为红色(TODO)。
package main
import (
"math/rand/v2"
"github.com/eihigh/miniten"
)
var (
x = 200.0
y = 150.0
vy = 0.0 // Y方向速度(Velocity of y)的缩写
g = 0.1 // 重力加速度(Gravity) 的缩写
jump = -4.0 // 跳跃力
frames = 0 // 经过的帧总数
interval = 120 // 墙的追加间隔
wallStartX = 640 // 墙的初始化x坐标
wallXs = []int{} // 墙的x坐标
wallWidth = 20 // 墙的宽度
wallHeight = 360 // 墙的高度
holeYs = []int{} // 空洞的y坐标
holeYMax = 150 // 空洞的y坐标的最大值
holeHeight = 170 // 空洞的大小(高度)
gopherWidth = 60
gopherHeight = 75
scene = "title"
score = 0 // 分数是全局变量
)
func main() {
miniten.Run(draw)
}
func draw() {
switch scene {
case "title":
drawTitle()
case "game":
drawGame()
case "gameover":
drawGameover()
}
}
func drawTitle() {
miniten.DrawImage("sky.png", 0, 0)
miniten.Println("点击开始游戏")
miniten.DrawImage("gopher.png", int(x), int(y))
if miniten.IsClicked() {
scene = "game"
}
}
func drawGame() {
miniten.DrawImage("sky.png", 0, 0)
// score := 0 // 删除这里
for i, wallX := range wallXs {
if wallX < int(x) {
score = i + 1
}
}
miniten.Println("Score", score)
if miniten.IsClicked() {
vy = jump
}
vy += g // 新的当前速度 = 当前速度+加速度
y += vy // 新的当前位置 = 当前位置+速度
miniten.DrawImage("gopher.png", int(x), int(y))
// 从这里开始,写追加土管的代码
frames += 1
if frames%interval == 0 {
wallXs = append(wallXs, wallStartX)
holeYs = append(holeYs, rand.N(holeYMax))
}
// 追加土管的代码,到这里就结束了
for i := range wallXs {
wallXs[i] -= 2 // 往左动
}
for i := range wallXs {
// 描绘上面的土管
wallX := wallXs[i]
holeY := holeYs[i]
miniten.DrawImage("wall.png", wallX, holeY-wallHeight)
// 描绘下面的土管
miniten.DrawImage("wall.png", wallX, holeY+holeHeight)
// 制作表示地鼠的矩形
aLeft := int(x)
aTop := int(y)
aRight := int(x) + gopherWidth
aBottom := int(y) + gopherHeight
// 定义表示上面土管的矩形
bLeft := wallX
bTop := holeY - wallHeight
bRight := wallX + wallWidth
bBottom := holeY
// 上面的土管的判定
if aLeft < bRight &&
bLeft < aRight &&
aTop < bBottom &&
bTop < aBottom {
scene = "gameover"
}
// 定义表示下面土管的矩形
bLeft = wallX
bTop = holeY + holeHeight
bRight = wallX + wallWidth
bBottom = holeY + holeHeight + wallHeight
// 地鼠与下面的土管的碰撞判定
if aLeft < bRight &&
bLeft < aRight &&
aTop < bBottom &&
bTop < aBottom {
scene = "gameover"
}
}
if y < 0 {
scene = "gameover"
}
if 360 < y {
scene = "gameover"
}
}
func drawGameover() {
// 将背景、地鼠、土管的drawGame函数粘贴到这里
miniten.DrawImage("sky.png", 0, 0)
miniten.DrawImage("gopher.png", int(x), int(y))
for i := range wallXs {
// 描绘上面的土管
wallX := wallXs[i]
holeY := holeYs[i]
miniten.DrawImage("wall.png", wallX, holeY-wallHeight)
// 描绘下面的土管
miniten.DrawImage("wall.png", wallX, holeY+holeHeight)
}
miniten.Println("Game Over")
miniten.Println("Score", score)
}
这下,我们已经完成了游戏结束画面的绘制。

然后同样在游戏结束画面点击后会显示 scene = "title" 就结束了……本来想这么说,但遗憾的是这并不奏效。
- 游戏结束画面如果
miniten.IsClicked()为 true,则变为scene = "title" - 在下一个帧中进行显示标题画面的处理
- 在下一个帧中(除非在一个帧内瞬间放开手指),
miniten.IsClicked()将再次变为 true - 因此游戏结束的标题画面瞬间被跳过,游戏再次开始
因此,需要写一种判断“玩家没有持续按下按钮,只按下了一瞬间”的方法,以及重置游戏状态的处理。
刚好被按下的判定
要判断按钮没有持续按下,而只是按下一瞬间,可以判断前一帧没有被按下,同时当前帧被按下了即可。这个处理在任何画面中都很方便,所以我们可以把它写在 draw 函数的开头。prev 是 previous(前一个)的缩写。
! 表示否定/not, !true 等于 false , !false 等于 true 。
package main
import (
"math/rand/v2"
"github.com/eihigh/miniten"
)
var (
// ...中略...
scene = "title"
score = 0
isPrevClicked = false // 前一帧按钮没按下
isJustClicked = false // 这一帧按钮被按下
)
func main() {
miniten.Run(draw)
}
func draw() {
// 这一帧按钮是否被按下 = 前一帧按钮没按下、同时这一帧按钮被按下
isJustClicked = miniten.IsClicked() && !isPrevClicked
// 为了下一帧做判断、保存“这一帧按钮被按下”这个状态
isPrevClicked = miniten.IsClicked()
switch scene {
case "title":
// ...后略...
将这个 isJustClicked 用于游戏结束画面和标题画面。
func drawTitle() {
miniten.DrawImage("sky.png", 0, 0)
miniten.Println("点击开始游戏")
miniten.DrawImage("gopher.png", int(x), int(y))
if isJustClicked {
scene = "game"
}
}
// ...中略...
func drawGameover() {
// 将背景、地鼠、土管的drawGame函数粘贴到这里
miniten.DrawImage("sky.png", 0, 0)
miniten.DrawImage("gopher.png", int(x), int(y))
for i := range wallXs {
// 描绘上面的土管
wallX := wallXs[i]
holeY := holeYs[i]
miniten.DrawImage("wall.png", wallX, holeY-wallHeight)
// 描绘下面的土管
miniten.DrawImage("wall.png", wallX, holeY+holeHeight)
}
miniten.Println("Game Over")
miniten.Println("Score", score)
if isJustClicked {
scene = "title"
}
}
这下可以安全地按住而不会被跳过了。
游戏结束时的重置
接下来只需重置游戏状态,以便从头开始重新游玩。gopher 君和土管的状态将在游戏结束画面中保持以便绘制,并希望在返回标题画面的瞬间重置,因此将在 if isJustClicked 中编写重置处理。
if isJustClicked {
scene = "title"
x = 200.0
y = 150.0
vy = 0.0
frames = 0
wallXs = []int{}
holeYs = []int{}
score = 0
}
这下可以全面体验游戏了。辛苦了!㊗️🎊💯💯
更多的磨练
因此,本书中制作的 flappy 游戏暂时完成,但仍然有进一步打磨的空间。即使不加新功能,也可以
- 让玩家角色动起来
- 利用滚动效果精心设计背景。
- 将碰撞判定的大小缩小以提升舒适感。
- 随着进程的推进,逐渐改变洞口的高度、宽度和移动速度等,增加难度。
可以考虑这样的打磨方式,如果之后入门 Ebitengine,
- 旋转玩家角色等。
- 碰到土管时发出声音。
- 更改显示分数的位置。
- 添加游戏结束的演出。
等等,有无数的事情可以做。即使在程序上并不困难实现,也有很多潜在的优化点可以大大提升趣味性,所以请不要满足于此,追求属于你自己的原创游戏吧。
扩展
这次制作的跳跃 gopher 君游戏包含了跳跃的行为和碰撞判定,可以说是动作游戏(沙盒游戏/平台游戏)的基础。唯一的区别是,当碰到地形时是游戏结束还是可以站在地形上。所有的一切都是相互关联的呢~。在本书中稍后会实际尝试制作动作游戏(可能。计划中)。
本章总结
通过画面迁移,我们整理了整个程序的流程,学习了构建游戏的流程。这种画面迁移,或者广义上的状态迁移(例如 isJustClicked),是程序中较难适当管理的内容之一。当能够编写画面迁移时,我认为可以作为一名合格的程序员而自豪了。
如前所述,虽然程序本身仍然有很多可以改进的空间,但是本章的地鼠游戏,将在这里告一段落。从下次开始,我们将挑战不同的事物。
第十五章 发布游戏让朋友玩(编译和分发)
前几章顺利完成了一款游戏。既然制作了有趣的游戏,就希望其他人也能来玩。因此这次将介绍游戏的分发和游玩方法。
近年来,通过网络分发游戏并让人们玩耍已变得越来越普遍。Ebitengine 在网络分发方面也非常方便,因此请尽情将您的力作向世界发布吧!
编译
在编程世界中,创造某种成果物被广泛称为编译(build)。成果物的种类有很多,但本书所讨论的编译是“制作可以作为游戏运行的文件”。让我们在这一页学习编译的方法,旨在分发成果物并让人们来玩。
Go 的编译是不可或缺的,实际上到目前为止多次执行的 go run . 命令,在后台已经将“编译和执行成果物”这两个步骤合并在一起。要单独进行编译并生成成果物,可以使用 go build 命令。
$ go build
然后目录中应该生成了可执行文件。在 Windows 中,文件会带有 .exe 扩展名,但在 macOS 或 Linux 中则没有扩展名。请通过资源管理器或其他方式打开生成的文件,双击运行,确认游戏是否如以前一样正常运行。
余谈:编译与链接
GGo 的编译由两个主要步骤组成:将程序翻译成机器语言的编译(compile),以及将其汇总为可执行文件的链接(link)。然而,由于链接步骤的存在感较弱,以及为了区分不需要编译的语言,加上多年的惯例,整个编译过程常常被称为编译。
这下成果物终于完成了,接下来只要分发就可以了……本来想这么说,但实际上并不是这样。
嵌入资源
请尝试将此可执行文件移动到桌面等其他位置后再运行。此时,图像应该不会显示。这是因为可执行文件正在读取同一目录中的图像文件,而在其他位置找不到这些文件,因此无法显示。
作为对策,将图像文件与可执行文件一起移动就可以正常运行,但 Go 有更智能的方法。那就是嵌入(embed)。嵌入是在编译时将所需文件作为可执行文件的一部分嵌入,这样只需携带一个可执行文件就能实现的梦幻功能。
早速前回制作的弹跳 gopher 游戏进行嵌入尝试。要点如下:
import "embed"导入embed包//go:embed这个特殊的注释用于指定要嵌入的文件//和go:embed之间不要留空格!*.png的意思是指定“同一目录下的所有 PNG 文件”
- 紧接着声明变量
var fsys embed.FS- 通过这个变量可以使用嵌入的文件
miniten.DrawImageFS函数用于绘制嵌入的图像- DrawImage 函数取
名前, X, Y个参数,但 DrawImageFS 取fsys, 名前, X, Y个参数 - 数量虽然很多,但努力将其全部替换
- DrawImage 函数取
其他详细信息
embed 模式的指定方法有些特殊,详细信息请参阅官方文档(英文)。
//go:embed 的方式, // 之后不接空格,然后 A:B 这样书写格式的特殊注释称为编译指令(directive)。Go 还有其他编译指令,如//go:generate。
package main
import (
"embed"
"math/rand/v2"
"github.com/eihigh/miniten"
)
//go:embed *.png
var fsys embed.FS
var (
x = 200.0
y = 150.0
vy = 0.0 // Y方向速度(Velocity of y)的缩写
// ...中略...
)
func main() {
miniten.Run(draw)
}
func draw() {
// ...中略...
}
func drawTitle() {
miniten.DrawImageFS(fsys, "sky.png", 0, 0)
miniten.Println("点击开始游戏")
miniten.DrawImageFS(fsys, "gopher.png", int(x), int(y))
if isJustClicked {
scene = "game"
}
}
func drawGame() {
miniten.DrawImageFS(fsys, "sky.png", 0, 0)
// 接下来,将所有的 DrawImage 全部替换成 DrawImageFS
// ...后略...
这个程序将正常编译。
$ go build
将生成的可执行文件移动到其他位置并尝试运行。应该会显示图像。
考虑如何分发游戏
生成的可执行文件可以通过 USB 闪存、便携式 SSD,或者使用 Google Drive、Dropbox、GigaFile 等文件共享服务进行分发。然而,近年来,因安全原因对从互联网下载可执行文件的警惕性逐年提高,常常会出现明明已经分发却无法下载和执行的情况。因此,我推荐以 Web 浏览器可玩形式进行分发。
以网页浏览器可玩形式进行分发,用户可以在避免上述问题的同时,在浏览器上玩游戏。此外,这也是分发可在手机上玩的游戏的最短路径,具有强大的优势。
以可在网页浏览器中游玩的形式编译
在 Go 中,只需对编译命令稍作修改,就可以 WebAssembly 这种可以在 Web 浏览器中执行的格式进行编译。
macOS (zsh) 的使用更简单,因此先介绍它,
$ GOOS=js GOARCH=wasm go build -o game.wasm
只需像这样执行(注意不要在 = 的两侧添加空格)。此外,虽然可以不指定成果物的文件名 -o game.wasm ,但如果不指定,通常很难与普通的可执行文件区分,因此建议添加。
Windows 有点麻烦,而且在 PowerShell 和 cmd.exe 中的做法也不同,因此我将介绍一种可以随时通用(甚至在 macOS 上也能使用)的 go env -w 的方法。
$ go env -w GOOS=js GOARCH=wasm
$ go build -o game.wasm
$ go env -u GOOS GOARCH
go env -w 是一个用于修改环境变量(类似于设置值)的命令,这里正在修改 GOOS 和 GOARCH 。不过,这种修改将会一直生效,因此在编译工作完成后,请使用 go env -u 恢复原状。
关于环境变量
环境变量是指存储有关程序执行环境的信息的操作系统提供的变量。通过使用 go env 命令,可以查看当前环境变量的值。
在 Go 中,可以使用 GOOS 和 GOARCH 这两个环境变量来指定编译时的目标操作系统和架构。为与使用 Go 的环境不同的操作系统或架构进行编译的过程称为交叉编译。
Go 的一个特点是交叉编译非常简单。
GOOS=js GOARCH=wasm 是什么
GOOS=js 表示 Web 浏览器,GOARCH=wasm 表示 WebAssembly。 js 是 JavaScript 的缩写。这个设置值的规则,应该有过有过深入的讨论,但作者并没有深入研究。
无论如何,生成 game.wasm 这个文件就是成功。
执行 WebAssembly 的办法
WebAssembly 形式无法通过双击执行。执行需要浏览器。而且在 Go 的情况下,除了 WebAssembly 形式的成果物外,还需要准备一些其他文件,但令人惊讶的是,可以使用作者制作的 Go Wasm Player 轻松执行!
访问 go-wasm-player.pages.dev,只需拖放或点击选择 game.wasm ,即可运行 WebAssembly 格式的游戏。请务必试试看。

发布游戏
成功自己确认可以在 Go Wasm Player 中游玩后,接下来只需通过文件共享服务等分发这个 game.wasm 。与想要游玩的人分享 game.wasm ,让他们在 Go Wasm Player 中游玩。当然,手机也可以像电脑一样游玩。我认为, game.wasm 的分发与可执行文件的分发不同,应该不会因为安全问题而被拒绝。
文件共享服务的使用方法就不赘述了,但像 Google Drive 和 Dropbox 等许多云存储不仅有存储自己文件的功能,还有与他人共享文件的功能,所以我们应该积极使用。
注意事项
不过,正如之前提到的,miniten 的 draw 函数被调用的频率可能因环境而异(具体来说,可能会与显示器的刷新率相匹配)。这意味着游戏速度可能会因环境而变化。Ebitengine 提供了在任何环境中以固定频率调用的 Update 函数,因此如果您想正式发布游戏,建议您继续阅读本书,尽快从 miniten毕业,并使用 Ebitengine 的 Update 函数。
此外,Go Wasm Player 的选择框在编译 game.wasm 时与 Go 版本不匹配会导致错误,因此可以告诉玩家 Go 的版本,或者从右上角的 Share wasm 分享包含 Go 版本的链接。
更进一步的分发方式
Go Wasm Player 需要先下载 game.wasm 文件,但 WebAssembly 的真正优势在于无需下载。像这款游戏“猫的愿望能实现”,只需点击链接即可玩游戏。
要实现这一点,您需要自己发布网页。虽然详细信息省略了,但使用这个 wasmgame,您可以在 GitHub Pages 的机制上免费发布原创网页,并在其中嵌入游戏。如果您有 HTML 和 CSS 的知识,您还可以自由设计游戏外部的页面。现在即使不签订租用服务器的合同也可以发布网页哦,太太。
或者,您也可以利用 itch.io 或 PLiCy 等游戏发行平台。我想您会被要求提供一个包含所需文件的 zip 文件,但 wasmgame 也支持生成这个 zip 文件,所以请务必尝试一下。利用这些平台来推广自己的游戏,或者参加游戏开发节(在有限的时间和特定主题下制作游戏的活动)来提升自己的技能,都是不错的选择。
移动应用开发
可以通过真正的移动应用程序进行分发,而不是通过浏览器。有关详细信息,请参阅 Ebitengine 的官方文档或志愿者撰写的文章。
Go 和 Ebitengine 非常易于使用,但由于移动应用需要遵循 iOS 和 Android 施加的限制,因此稍微有些难度。如果您感兴趣,请务必尝试一下。
家庭用游戏机开发
其实 Ebitengine 也支持 Nintendo Switch 和 Xbox!
...不过,前提是得到任天堂或微软的人邀请为他们开发游戏,所以可能最好把它当作未来的美好梦想来期待。
不过,手边运行的游戏能够直接在 Nintendo Switch 等平台上运行,确实是个非常吸引人的话题。 对技术话题感兴趣的朋友可以参考这篇文章。
使用 Go 命令进行游戏
最后,介绍一种开发者之间共享游戏的最简单方法。实际上在环境编译页面中也有介绍, go run 可以直接在互联网上运行程序。这个命令将执行 Ebitengine 的示例游戏“跳跃的 gopher 君”。
$ go run github.com/hajimehoshi/ebiten/v2/examples/flappy@latest
! 请只运行可信的程序!
此命令直接执行互联网上的程序,如果执行恶意程序,将完全无防备,允许入侵计算机。执行时请务必确认对方是可信的人。
在互联网上放置程序,GitHub 这个服务是最流行和方便的选择。虽然界面只有英文,但有很多热心人士提供的资料,所以一定没问题。
在 Go 中处理这种 go run 时,需要正确初始化 go.mod 。例如,如果我 (eihigh) 要将其发布到名为 flappy 的仓库(程序存放处),
$ go mod init github.com/eihigh/flappy
将其初始化为如上所示。在此状态下,照常进行开发,并推送到 GitHub(推送与上传同义),即可运行 go run github.com/eihigh/flappy@latest 。
GitHub 是开发者之间交流的场所,目前是世界上最活跃的地方,因此如果有机会,强烈推荐您尝试一下。
本章总结
这次介绍了如何编译和分发游戏。如果您有其他语言的经验,您一定会对为多种环境编译的简单性感到惊讶。此外,嵌入功能是 Go 游戏开发中特别容易感受到好处的功能,请务必好好利用。
通过网络分发游戏在最近变得非常普遍,因此一旦能够通过网络分发,能够做的事情将大大增加,更多的人将能够享受自己的游戏。移动设备、家用游戏机以及 Steam 等更是更进一步,但当然 Ebitengine 是支持的,因此将其作为未来的目标也是一个不错的选择。
第十六章 把数据放在一起(结构体)
上次我们学习了如何构建和分发完成的游戏。这次我们将正式开始学习 Go 和 Ebitengine,重点讲解之前不足的 Go 功能,以及基于这些功能的 Ebitengine 使用方法。 首先,让我们学习如何使用结构体来整合数据。
结构体
结构体/struct 是将多个数据汇总为一个的功能,类似于便当盒,可以将多个物品组合在一起。结构体最简单的用法如下所示。
package main
import "fmt"
var p struct { // p 是 位置/position的首字母
x int
y int
}
func main() {
p.x = 3
p.y = 4
fmt.Println(p)
}
$ go run .
{3 4}
中间的换行可能会让人觉得怪,但变量 p 的类型是 struct 的{ 到 } 。

通过这种方式定义类型,变量 p 将内部包含两个 int 类型的变量 x 和 y 。结构体内部的每一个变量称为字段/field,并在结构体类型的变量名后加上点 . 来取值。在上述程序中,分别给 p 中的 p.x 和 p.y 赋值。
类型宣言
var x0 int
var x1 int
增加一个 int 类型的变量很简单,但如果想增加结构体类型的变量,由于类型中有换行,且较长,准确地写出所有字段的名称和类型就很麻烦。因此,通常会在类型声明中给结构体起一个喜欢的别名,以便于重复使用。类型宣言的语法是 type 新的类型名 原类型名 ,如下所示。
// 用构造体为基础,声明新的类型 position
type position struct {
x int
y int
}
// 使用新的类型
var p0 position
// 可以多次利用
var p1 position
var p2 position
// p0, p1, p2 都有 x, y 这两个字段
类型宣言中还有“能够声明类型相关的函数”这个附带效果,但这稍后再说。
扩展解说
实际上不仅限于结构体, int 等各种类型都可以作为基础进行类型声明,但这是声明方法的高级用法,因此省略。
结构体的值的表示
结构体也可以像数组和切片一样使用复合字面量通过 类型名称{字段的值, 字段的值...} 表示值。
// 假设已经声明了 position
func main() {
p := position{3, 4} // x的值、y的值
fmt.Println(p) // {3 4}
}
此外,还可以使用 {字段名称: 字段值, 字段名称: 字段值 ...} 这种语法。用法是
func main() {
p := position{x: 3, y: 4}
fmt.Println(p) // {3 4}
}
就是这样的感觉。
实际上
实际上,在合成字面量的字段名处放置索引的方式也可以使用这种语法来处理数组和切片,但由于不常用,所以不记住也没关系。
结构体的零值
结构体在未赋值时,各字段将为零值。如果是 position 类型,那么 x 和 y 也将是 0 。
var p position
fmt.Println(p) // {0 0}
{字段名: 值, ...} 这种记法,允许省略字段。省略的字段将为零值。
p := position{y: 5} // x 被省略了
fmt.Println(p) // {0 5}
结构体的优点
那么,通过使用结构体,我们可以将多个变量合并为一个。这有什么好处呢?
优势 1:变量声明的省力化
var px int
var py int
如果只有一组变量,作为不同的变量分开写,似乎也没什么问题,
var p0x int
var p0y int
var p1x int
var p1y int
// ...以下略...
但是像这样,使用多个变量的集合就很麻烦了。这时候,结构体就派上用场了。
var p0 position // 包含 p0.x, p0.y
var p1 position // 包含 p1.x, p1.y
// ...以下略...
优势二:批量复制
结构体的复制会复制所有字段。如果没有结构体的这个工具,就只有
p1x := p0x
p1y := p0y
这么写了。
但是因为有结构体,可以写成:
p1 := p0
简单许多吧。
优势 3:可选值
已经看到,省略字段的表达在结构体的值中是存在的。
再贴一遍
p := position{y: 5} // 省略了 x
fmt.Println(p) // {0 5}
这是将选项表示为结构体时很方便。我们来考虑以下的“咖喱饭的订单选项”结构体。
type 独家秘制咖喱饭 struct {
老板加点饭 int // 要加多少克
要不要叉烧 bool // 是否加叉烧
来几根脆脆肠 int // 要加几根脆脆肠
}
结构体可以省略不必要的字段,因此可以有以下各种用法。
order(独家秘制咖喱饭{}) // 标准版咖喱饭
order(独家秘制咖喱饭{老板加点饭: 200}) // 多来点饭
order(独家秘制咖喱饭{要不要叉烧: true, 来几根脆脆肠: 3}) // 加叉烧、三根脆脆肠(不加米饭)
[面向有经验的人] 使零值有用
“Make the zero value useful” 是 Go 的一个谚语,意思是“让零值有用”。
结构体中省略的字段会被赋予零值,但将“零值定义为有用的值”被认为是写好程序的技巧。
例如在上述例子中,米饭的追加量是“从普通份增加多少克”。
如果改为“米饭的要多少克”的话,如果没有设定这个字段,米饭的量就会变成 0,因为咖喱饭不能没有米饭,因此零值无效,是“无用的值”。
在这里,设置这个字段为“加多少饭”,会让此字段未赋值的时候,自然地使用标准份量,程序显得智能。这就是“让零值有用”的思维方式。
优势 4:与切片结合
var xs = []int{}
var ys = []int{}
使用结构体可以将“用两个切片表示 x, y 的多个配对”的传统方法整合到一个切片中。
var ps = []position{}
切片的优点在于可以随时自由地添加或删除元素,但如果管理两个切片,可能会出现在一个切片中添加了元素而忘记在另一个切片中添加的错误。通过将元素的类型设置为结构体并在一个切片中管理,可以避免此类错误的发生。这也是结构体的一个优点。
重构吧
使用方便的结构体,我们来重构一下地鼠游戏。
重构是指在不改变程序行为的情况下,仅改善写法。
请注意两个变量 wallXs 和 holeYs 。
var wallXs = []int{} // 土管的X坐标
var holeYs = []int{} // 空洞的Y坐标
这些可以通过使用结构体,将两个变量汇总成一个 `walls。
var walls = []struct{
wallX int
holeY int
}
2两个切片...
合并为一个*
我们可以基于此进行重构。range 语句的处理方式与之前有所不同,不再从两个切片中获取值,因此使用循环次数 i 的机会减少了。
package main
import (
"embed"
"math/rand/v2"
"github.com/eihigh/miniten"
)
//go:embed *.png
var fsys embed.FS
type wall struct {
wallX int
holeY int
}
var (
x = 200.0
y = 150.0
vy = 0.0 // Y方向速度(Velocity of y)的缩写
g = 0.1 // 重力加速度(Gravity) 的缩写
jump = -4.0 // 跳跃力
frames = 0 // 经过的帧总数
interval = 120 // 土管的追加间隔
wallStartX = 640 // 土管的初始化x坐标
walls = []wall{} // 土管的X坐标与空洞的Y坐标
wallWidth = 20 // 土管宽度
wallHeight = 360 // 土管高度
holeYs = []int{} // 空洞的Y坐标
holeYMax = 150 // 空洞的Y坐标的最大值
holeHeight = 170 // 空洞的的大小(高度)
gopherWidth = 60
gopherHeight = 75
scene = "title"
score = 0 // 保存分数的全局变量
isPrevClicked = false // 上一帧是否被点击
isJustClicked = false // 这一帧是否被点击
)
func main() {
miniten.Run(draw)
}
func draw() {
// 这一帧按钮是否被按下 = 前一帧按钮没按下、同时这一帧按钮被按下
isJustClicked = miniten.IsClicked() && !isPrevClicked
// 为了下一帧做判断、保存“这一帧按钮被按下”这个状态
isPrevClicked = miniten.IsClicked()
switch scene {
case "title":
drawTitle()
case "game":
drawGame()
case "gameover":
drawGameover()
}
}
func drawTitle() {
miniten.DrawImageFS(fsys, "sky.png", 0, 0)
miniten.Println("点击后开始")
miniten.DrawImageFS(fsys, "gopher.png", int(x), int(y))
if isJustClicked {
scene = "game"
}
}
func drawGame() {
miniten.DrawImageFS(fsys, "sky.png", 0, 0)
for i, wall := range walls {
if wall.wallX < int(x) {
score = i + 1
}
}
miniten.Println("Score", score)
if miniten.IsClicked() {
vy = jump
}
vy += g // 新的当前速度 = 当前速度+加速度
y += vy // 新的当前位置 = 当前位置+速度
miniten.DrawImageFS(fsys, "gopher.png", int(x), int(y))
// 土管相关的处理
frames += 1
if frames%interval == 0 {
wall := wall{wallStartX, rand.N(holeYMax)}
walls = append(walls, wall)
}
// 土管相关的处理结束
for i := range walls {
walls[i].wallX -= 2 // 少しずつ左へ
}
for _, wall := range walls {
// 描绘上方的土管
miniten.DrawImageFS(fsys, "wall.png", wall.wallX, wall.holeY-wallHeight)
// 描绘下面的土管
miniten.DrawImageFS(fsys, "wall.png", wall.wallX, wall.holeY+holeHeight)
// 制作表示地鼠的矩形
aLeft := int(x)
aTop := int(y)
aRight := int(x) + gopherWidth
aBottom := int(y) + gopherHeight
// 定义表示上面土管的矩形
bLeft := wall.wallX
bTop := wall.holeY - wallHeight
bRight := wall.wallX + wallWidth
bBottom := wall.holeY
// 上面土管的判定
if aLeft < bRight &&
bLeft < aRight &&
aTop < bBottom &&
bTop < aBottom {
scene = "gameover"
}
// 定义表示下面土管的矩形
bLeft = wall.wallX
bTop = wall.holeY + holeHeight
bRight = wall.wallX + wallWidth
bBottom = wall.holeY + holeHeight + wallHeight
/// 地鼠与下面土管的碰撞判定
if aLeft < bRight &&
bLeft < aRight &&
aTop < bBottom &&
bTop < aBottom {
scene = "gameover"
}
}
if y < 0 {
scene = "gameover"
}
if 360 < y {
scene = "gameover"
}
}
func drawGameover() {
// 把绘制背景、地鼠、土管的代码粘贴到这里
miniten.DrawImageFS(fsys, "sky.png", 0, 0)
miniten.DrawImageFS(fsys, "gopher.png", int(x), int(y))
for _, wall := range walls {
// 描绘上方的土管
miniten.DrawImageFS(fsys, "wall.png", wall.wallX, wall.holeY-wallHeight)
// 描绘下面的土管
miniten.DrawImageFS(fsys, "wall.png", wall.wallX, wall.holeY+holeHeight)
}
miniten.Println("Game Over")
miniten.Println("Score", score)
if isJustClicked {
scene = "title"
x = 200.0
y = 150.0
vy = 0.0
frames = 0
walls = []wall{}
holeYs = []int{}
score = 0
}
}
您觉得怎么样?由于结构体的字段只有两个,可能会让一些人觉得改善不显著,但工程规模越大,受益就会越明显。
本章总结
我们学习了将多个变量汇总为一个的功能——结构体。如果使用得当,可以使程序的意图更加明确,节省人力等多种好处。当你想到“这里可以使用结构体吗?”时,尝试一下,通过积累经验,你将能够编写出更聪明、更有条理的程序。
第十七章 把处理放在一起(函数声明)
前回是学习了如何使用结构体来汇总数据。这次,我们将学习如何使用函数来汇总处理。这次也有重构的环节哦。
函数
复习一下吧。
函数是某种处理的集合,可以传递参数(输入)并返回返回值(输出)。
此外,通过将长处理切分为函数,我们也改善了程序的可读性。这次我们将重新学习函数的声明方法。
参数的用法
使用参数时,函数声明为 func 函数名(参数, 参数, ...) { ... } 的形式。
引数可以像普通变量一样使用。
func printMax(a int, b int) {
fmt.Println("最大值是", max(a, b))
}
顺便提一下,在参数列表和返回值列表中(实际上变量声明和结构体字段也是如此),如果相同类型连续出现,可以省略最后一个参数类型。
func printMax(a, b, c int) { // a int, b int, c int 的简略写法
返回值的用法与 return
返回值的函数同样以 func 函数(参数, 参数, ...) (返回值, 返回值, ...) { ... } 的形式声明。
func minMax(a, b int) (minVal, maxVal int) {
return min(a, b), max(a, b)
}
// ※ 为了避免与函数 min, max 重名,这里将参数命名为 minVal, maxVal
新出现了 return (返回)关键字。 return 是结束函数处理并返回返回值的关键字。
return 会在此处结束函数的处理,因此可以进行这样的操作。我们称这种在函数开头进行条件分支并提前结束处理的方式为早期返回/early return,这是一种常被推荐的技术,以保持程序的整洁。
func minMax(a, b int) (minVal, maxVal int) {
if a < b {
return a, b // 满足条件的时候 return
}
// 只有上面↑的条件没有满足的时候、才会执行下面↓的语句
return b, a
}
此外, return 也可以作为没有返回值的函数,仅用于结束处理。
func fukubiki() {
if rand.N(2) == 1 {
fmt.Println("中奖了")
return
}
fmt.Println("没中奖")
// return ← 因为没有返回值,所以这一句可以省略
}
返回值为 None 的函数可以在不使用 return 的情况下结束。另一方面,返回值的函数必须使用 return 返回返回值并结束。
返回值的省略形式
函数声明的返回值部分有多种省略形式。
首先可以不为返回值命名,仅指定类型。
func minMax(a, b int) (int, int) {
如果返回值没有起名,且只有一个返回值,可以省略包裹返回值的 () 。
func add(a, b int) int {
最后,正如您所知,返回值为空时,无需写任何返回值相关内容。
func fukubiki() {
!此外,还有方法的声明、带类型参数的函数、带类型参数的函数方法,这些将在后面介绍。
模式有很多,但一开始并不需要完全记住所有语法。相反,可以先记个大概,然后写出来试试,如果出错误再修正。这样编程会更加顺利。
函数的使用场合
正如已经使用过的那样,除了将长时间的处理切分以便于查看外,当同样的处理需要重复多次时,声明函数并重新使用也是很方便的。
func drawComplexObjects() {
// 已经写好的数百行描绘处理
}
func draw() {
// 可以简单地再利用
drawComplexObjects()
drawComplexObjects()
drawComplexObjects()
}
此外,通过合理设计函数的参数和返回值,可以明确程序的边界,并获得可扩展性、可维护性和可读性,但这感觉是相当高级的技术,留待以后再说。
重构吧
那么和上次一样,我们来重构跳跃的 gopher 游戏,练习将处理汇总到函数中的方法。例如,以下这些点似乎可以汇总到函数中。
- 上面的墙和下面的墙的绘制处理在游戏画面和游戏结束画面中是共通的,因此可以进行合并
- 四角形之间的碰撞检测计算公式在上壁和下壁是共通的,因此可以进行整合
当然,还有无数其他方法可以将其汇总为函数,但请您理解这是为了说明。
package main
import (
"embed"
"math/rand/v2"
"github.com/eihigh/miniten"
)
//go:embed *.png
var fsys embed.FS
type wall struct {
wallX int
holeY int
}
var (
// ...省略...
)
func main() {
miniten.Run(draw)
}
func draw() {
// ...省略...
}
func drawTitle() {
// ...省略...
}
func drawGame() {
// ...前略...
for i := range walls {
walls[i].wallX -= 2 // 少しずつ左へ
}
for _, wall := range walls {
// 上の壁の描画
miniten.DrawImageFS(fsys, "wall.png", wall.wallX, wall.holeY-wallHeight)
// 下の壁の描画
miniten.DrawImageFS(fsys, "wall.png", wall.wallX, wall.holeY+holeHeight)
drawWalls(wall)
// gopherくんを表す四角形を作る
aLeft := int(x)
aTop := int(y)
aRight := int(x) + gopherWidth
aBottom := int(y) + gopherHeight
// 上の壁を表す四角形を作る
bLeft := wall.wallX
bTop := wall.holeY - wallHeight
bRight := wall.wallX + wallWidth
bBottom := wall.holeY
// 上の壁との当たり判定
if aLeft < bRight &&
bLeft < aRight &&
aTop < bBottom &&
bTop < aBottom {
scene = "gameover"
}
if hitTestRects(aLeft, aTop, aRight, aBottom, bLeft, bTop, bRight, bBottom) {
scene = "gameover"
}
// 下の壁を表す四角形を作る
bLeft = wall.wallX
bTop = wall.holeY + holeHeight
bRight = wall.wallX + wallWidth
bBottom = wall.holeY + holeHeight + wallHeight
// 下の壁との当たり判定
if aLeft < bRight &&
bLeft < aRight &&
aTop < bBottom &&
bTop < aBottom {
scene = "gameover"
}
if hitTestRects(aLeft, aTop, aRight, aBottom, bLeft, bTop, bRight, bBottom) {
scene = "gameover"
}
}
if y < 0 {
scene = "gameover"
}
if 360 < y {
scene = "gameover"
}
}
func drawGameover() {
// 背景、gopher、壁の描画はdrawGame関数のコピペ
miniten.DrawImageFS(fsys, "sky.png", 0, 0)
miniten.DrawImageFS(fsys, "gopher.png", int(x), int(y))
for _, wall := range walls {
// 上の壁の描画
miniten.DrawImageFS(fsys, "wall.png", wall.wallX, wall.holeY-wallHeight)
// 下の壁の描画
miniten.DrawImageFS(fsys, "wall.png", wall.wallX, wall.holeY+holeHeight)
drawWalls(wall)
}
miniten.Println("Game Over")
miniten.Println("Score", score)
if isJustClicked {
scene = "title"
x = 200.0
y = 150.0
vy = 0.0
frames = 0
walls = []wall{}
score = 0
}
}
func drawWalls(w wall) {
// 上の壁の描画
miniten.DrawImageFS(fsys, "wall.png", w.wallX, w.holeY-wallHeight)
// 下の壁の描画
miniten.DrawImageFS(fsys, "wall.png", w.wallX, w.holeY+holeHeight)
}
func hitTestRects(aLeft, aTop, aRight, aBottom, bLeft, bTop, bRight, bBottom int) bool {
return aLeft < bRight &&
bLeft < aRight &&
aTop < bBottom &&
bTop < aBottom
}
怎么样,似乎又稍微看到了些希望呢?
什么时候、如何将其汇总为函数?
这次我们可以这样将其汇总为函数,但何时、怎么抽取函数是一个相当困难的问题。
例如这次,汇总了游戏画面和游戏结束画面的墙壁绘制处理,但如果出现了**“在游戏结束画面时想要改变碰到的墙壁外观”**这个需求,那该怎么办呢?在这种情况下,使用通用函数处理会有些困难,因此最好不要进行汇总。像这样,是否应该将处理汇总为函数,答案将会因时而异。
“多一点复制,总比多一点依赖更好。”这是一个 Go 谚语。这意味着,复制一些程序总比有依赖关系(A 需要 B 的关系,函数调用也是一种依赖关系)要好。“多一点”这个说法有些含混,不过这种规则本来就很难整齐划一,不过分拘泥于规则也很重要。
个人推荐“如果同样的处理出现了3次,就进行共通化”。出现 2次的时候,会有“不写在一起更好”的可能性,到了第3次时判断“这部分确实可以共通化”。我认为这样做可以保持相当的平衡。基于这一点,刚才的重构就显得不太合理了。但请您谅解,这么做是为了学习。
本章总结
我们学习了如何使用函数来汇总处理。函数可以接收参数并返回值,并且在 return 处结束处理。使用函数,可以提高程序的可读性。然而是否应该将共同部分整理成函数,会因情况而异。因此我们要记住,共通化并不总是正确的做法。
第十八章 指针(Pointer)
这次,我们将讨论编程初学者的“鬼门”,指针。我认为比起 C 语言,觉得 Go 指针难的人并不多。但仍然会有不少人害怕它,因此我会仔细地解说。
指针
指针/pointer 是一个用来**“指出某物位置”**的词,储存变量等的“位置”。用法如下所示。
package main
import "fmt"
func main() {
x := 42
p := &x // xのアドレスをpに代入
fmt.Println(p) // 0xから始まる謎の数字
fmt.Println(*p) // 42
*p = 99 // pが指し示す場所に99を代入
fmt.Println(*p) // 99
fmt.Println(x) // 99
}
$ go run .
0x1400009a020
42
99
99
新出现的 & 和 * 是进行指针相关操作的符号。可能会让人感到困惑,我们来一步步看看。
计算机的内部结构
计算机的核心是进行计算的 **CPU(英特尔整天做广告的那玩意)**和用于存储数据的内存(8GB 或 16GB 等)。它们通常被比作人类的大脑和桌面上的工作区域。大脑越聪明,计算越快;桌面越大,工作空间越宽裕。
这张桌子上的所有数据都井然有序地排列着,每个数据都有一个表示位置的数字。这个数字就是地址/address。最初是表示“住所”的词,有时也被翻译为“番地”。
下图是这样的内存中变量 x y vy 被保存的样子。
!954、958、974 等地址仅仅是为了说明“存在地址这个数字”而临时指定的值,因此与实际地址的分配方式有很大不同。请注意。
内存内部的图像
变量存储在内存中,内存上的数据分配了地址,如果掌握了这些,指针就可以轻松应对了。
& 和 *
在 Go 中,如下图所示,可以通过 & 获取分配给变量的地址,通过 * 获取地址所指向的内容。

这样一来,开头的程序也一定能读懂了。
再贴一遍
package main
import "fmt"
func main() {
x := 42
p := &x // xのアドレスをpに代入
fmt.Println(p) // 0xから始まる謎の数字の正体は、xのアドレス!
fmt.Println(*p) // 42 (指し示す先はxなので)
*p = 99 // pが指し示すxに99を代入
fmt.Println(*p) // 99
fmt.Println(x) // 99
}
なお, fmt.Println(p) で显示的 0x ,这是 16 进制的意思。平常使用的 10 进制是用 0 到 9 的数字表示一位,而 16 进制则是用 0 到 9 和 A 到 F 共 16 种表示一位。在 Go 中,16 进制的前面加上 0x 以区分于 10 进制。
总之只是表示法不同而已,实际上只是整数,所以完全不用担心。例如 0x1400009a020 转换为十进制是 1374390165536 。地址使用十六进制只是一个习惯,所以不必太在意。
指针类型
下图中还有一个地方没有解释,那就是 p *int 的部分。

在 Go 中,可以将地址赋值给指针类型。指针类型以 *int 的形式写作 *型名 。
麻烦的是, & 获取的地址结果的类型不是 &int 而是 *int 。不过这是历史原因造成的,只能记住。个人来说,我希望指针类型叫 &int 。
零值,nil
指针类型的零值是 **nil(空)**这个特殊值。nil 表示不指向任何地方的无效值。
nil 指向的地方在 * 中使用时会崩溃,所以请小心使用。
func main() {
var p *int
*p = 42 // 崩溃
fmt.Println(*p)
}
利用 nil 的含义,也可以用来表示有无效和有效时的变量。
func main() {
p := 某个函数() // 返り値が無効な時と有効な時がある
if p != nil { // 有効な (nilではない) 時だけ処理を行う
fmt.Println(*p)
}
}
nil 不仅用于纯粹的指针类型,还用于 Go 提供的几种类型,表示空值。关于这一点我们会稍后再提。
结构体和指针
为了结合结构体和指针,提供了一些特殊的便利功能。
首先,在合成字面量前加上 & ,可以直接创建该结构体的指针,而无需通过变量。这是相当常用的。
pp := &position{10, 20}
// ↑と↓は同じ意味
p := position{10, 20}
pp := &p
另一个,当从结构体指针使用字段时,可以像普通结构体一样写 pp.x 。
pp := &position{10, 20}
pp.x = 30
fmt.Println(pp.x) // 30
如果没有这个功能,需要通过指针指向的结构体,那么在考虑 * 和 . 的优先级时,就必须写成 (*pp).x ,这真是一个非常感谢的功能。对于有 C 系语言经验的人来说,听到可以不必区分 . 和 -> ,应该就能明白了。
指针的使用场合
指针大致有三个使用场景。
- 避免大数据的复制
- 中身被改写
- 无效值用 nil 表示
最后一个已经提到过了,接下来我将对前两个进行解说。
使用场景 1:避免大量数据的复制
当将参数传递给函数时,该参数会被复制。如果该参数非常庞大,复制将耗费时间(复制庞大数据对计算机来说是重负担)。
指针只是一个数字,因此只需复制指针,就可以相对快速地在函数中通过指针使用该数据。

使用场景 2:要求更改原始内容
这也是由于参数被复制而引起的事情,但在函数内部对参数这个副本进行的任何修改都不会影响原始变量。
使用指针时,复制的指针也会指向原始变量,因此即使在函数内部也可以修改原始变量。
func f(x int) {
x = 99
}
func g(p *int) {
*p = 99 // 指针指向的地方 = 原始值被覆盖
}
func main() {
x := 42
f(x)
fmt.Println(x) // 42 x没有被更改
g(&x)
fmt.Println(x) // 99 x被更改了
}
猜数字游戏中出现的 fmt.Scanln 函数取参数为指针也是为了这个原因。
x := 0
fmt.Scanln(&x) // 此处的 x 被修改
其他用途
基本是以上所述,但在实践中正确区分所有指针,即使是熟练的程序员也非常困难。因此,这次我将传授一个大致可以说是 OK 的粗略指导。虽然提到了尚未涉及的 Go 功能,但请您谅解,并将其放在脑海的一角。
应使用指针的情况:
- 值写入函数
- 结构体型的参数
- 切片元素的类型
不需要使用指针的情况:
- 数值、真值型的参数(没有将其指针化的好处)
- 文字串、切片、映射、通道、闭包、接口类型的参数(实质上是指针类型)
重构
那么照例,我们来试试在重构中使用指针的感觉。根据上述指导方针,这次关注的是“切片元素的类型”和“结构体类型的参数”。在 var walls 和 drawWalls 附近似乎可以使用指针。
var walls = []*wall{}
func drawWalls(w *wall) {
那么我们来将其应用于整体。
package main
import (
"embed"
"math/rand/v2"
"github.com/eihigh/miniten"
)
//go:embed *.png
var fsys embed.FS
type wall struct {
wallX int
holeY int
}
var (
x = 200.0
y = 150.0
vy = 0.0 // Y方向速度(Velocity of y)的缩写
g = 0.1 // 重力加速度(Gravity) 的缩写
jump = -4.0 // 跳跃力
frames = 0 // 经过的帧总数
interval = 120 // 土管追加间隔
wallStartX = 640 // 土管的初始x坐标
walls = []*wall{} // 土管的X坐标与空洞的Y坐标
wallWidth = 20 // 土管的宽度
wallHeight = 360 // 土管的高度
holeYMax = 150 // 空洞的Y坐标的最大值
holeHeight = 170 // 空洞的大小(高度)
gopherWidth = 60
gopherHeight = 75
scene = "title"
score = 0 // 分数,全局变量。
isPrevClicked = false // 前一帧按钮没按下
isJustClicked = false // 这一帧按钮被按下
)
func main() {
miniten.Run(draw)
}
func draw() {
// ...省略...
}
func drawTitle() {
// ...省略...
}
func drawGame() {
miniten.DrawImageFS(fsys, "sky.png", 0, 0)
for i, wall := range walls {
if wall.wallX < int(x) {
score = i + 1
}
}
miniten.Println("Score", score)
if miniten.IsClicked() {
vy = jump
}
vy += g // 新的当前速度 = 当前速度+加速度
y += vy // 新的当前位置 = 当前位置+速度
miniten.DrawImageFS(fsys, "gopher.png", int(x), int(y))
// 从这里开始,写追加土管的代码
frames += 1
if frames%interval == 0 {
wall := &wall{wallStartX, rand.N(holeYMax)}
walls = append(walls, wall)
}
// 追加土管的代码,到这里就结束了
for i := range walls {
walls[i].wallX -= 2 // 往左动
}
for _, wall := range walls {
wall.wallX -= 2 // 往左动
}
for _, wall := range walls {
drawWalls(wall)
// ...省略...
}
if y < 0 {
scene = "gameover"
}
if 360 < y {
scene = "gameover"
}
}
func drawGameover() {
// 将背景、地鼠、土管的drawGame函数粘贴到这里
miniten.DrawImageFS(fsys, "sky.png", 0, 0)
miniten.DrawImageFS(fsys, "gopher.png", int(x), int(y))
for _, wall := range walls {
drawWalls(wall)
}
miniten.Println("Game Over")
miniten.Println("Score", score)
if isJustClicked {
scene = "title"
x = 200.0
y = 150.0
vy = 0.0
frames = 0
walls = []*wall{}
score = 0
}
}
func drawWalls(w *wall) {
// 描绘上面的土管
miniten.DrawImageFS(fsys, "wall.png", w.wallX, w.holeY-wallHeight)
// 描绘下面的土管
miniten.DrawImageFS(fsys, "wall.png", w.wallX, w.holeY+holeHeight)
}
func hitTestRects(aLeft, aTop, aRight, aBottom, bLeft, bTop, bRight, bBottom int) bool {
return aLeft < bRight &&
bLeft < aRight &&
aTop < bBottom &&
bTop < aBottom
}
最初提到的 walls 变量声明和 drawWalls 函数声明以外,
- 壁的附加处理(
&wall{...}) - 壁的移动处理(
wall.wallX -= 2) - 壁的重置处理(
walls = []*wall{})
对全局影响不大,意外地容易适用。
有趣的是墙壁的移动处理。 for range 文中提取的元素类型变成了指针,因此可以通过复制的 wall 指针来修改原始值,结果不再需要使用循环次数 i 。实际上,这种新的写法在 Go 中是常见的写法。
// before
for i := range walls {
walls[i].wallX -= 2 // 往左移动
}
// after
for _, wall := range walls {
wall.wallX -= 2 // 往左移动
}
本章总结
指针表示变量的位置。在 Go 中,使用 & 获取地址,使用 * 获取地址所指向的内容。指针用于避免大数据的复制或在函数中修改变量。
计算机的核心与其深度相关,想要正确使用即使是熟练者也会感到困难,但如果掌握基本方针,大多数情况下应该能够编写出合格的程序。
第十九章 将函数绑定到类型(method)
次我们将学习与类型相关的函数的处理。尽管功能简单,但它有可能大幅改善程序的编写体验。
将函数与类型绑定在一起
函数声明时,如果在函数名之前添加 (参数名 数据类型) 这样的部分,就可以将该函数与类型关联。与类型关联的函数也称为方法/method,可以像 变量名.方法名 这样如同一个字段一样使用。
先试试看吧。
// 型宣言
type user struct {
name string
age int
}
// 普通の関数宣言
func userPrintln(u *user) {
fmt.Println("名前:", u.name, "年齢:", u.age)
}
// メソッド宣言
// (u *user) という部分が、user型に紐づく関数であることを示している
func (u *user) println() {
// 処理は普通の関数と全く同じ
fmt.Println("名前:", u.name, "年齢:", u.age)
}
func main() {
u := user{name: "Taro", age: 20}
// 普通の関数の利用
userPrintln(u)
// メソッドの利用
// 変数名.メソッド名 で利用できる
u.println()
}
如您所见,仅通过在函数声明中更改 (u *user) 的位置,就可以将函数绑定到类型 *user ,并使其 变量名.方法 的语法可用。
函数名前的 (u *user) 这样的参数,有时被特别称为接收器/receiver。
函数的优点
函数的优点在于,只要类型不同,即使名称重复也没关系。
用专业术语来说,就是名称空间/namespace 被区分开了。
func (u *user) println() {
fmt.Println("名前:", u.name, "年齢:", u.age)
}
// 異なる型なら同じ名前のメソッドを宣言できる
func (p *position) println() {
fmt.Println("X座標:", p.x, "Y座標:", p.y)
}
func main() {
u := user{name: "Taro", age: 20}
u.println() // 名前: Taro 年齢: 20
p := position{x: 10, y: 20}
p.println() // X座標: 10 Y座標: 20
}
由于不必担心重名,因此方法名可以相对简短,没问题。
方法也是更重要的功能“接口”的基础,不过这部分稍后再说。现在只需记住“命名更容易,好方便啊~”就可以了。
与指针的关系
调用方法时,无论是指针还是非指针,都可以完全相同地使用 变量名.方法 。
func (u *user) println() {
fmt.Println("名前:", u.name, "年齢:", u.age)
}
func main() {
// 非ポインター変数を使う
taro := user{name: "Taro", age: 20}
taro.println()
// (&taro).println() ←こう書かなくて済む
// ポインター変数を使う
jiro := &user{name: "Jiro", age: 30}
jiro.println()
}
因此,在调用方法时,基本上不需要关心接收者是否是指针。
接收器相关的惯例
习惯上,接收器通常会取首字母作为 u 等名称。在其他语言中,通常会赋予 this 或 self 等特殊关键字,但 Go 则有所不同。此外,建议同一类型的方法统一接收器名称(避免在某个地方使用 u ,在另一个地方使用 usr 的情况)。
这只是习惯,所以并不是绝对的规则。
本章总结
这次我们学习了与类型相关的函数“方法”。这一回相对简单。



