在深入了解LibGDX详细的API之前,让我们先创建一个简单的游戏,它会对每个模块都有接触,我们将介绍一些概念,但是又会适可而止。
我们将来了解:
- 基本文件访问
- 清除屏幕
- 绘制图像
- 使用相机
- 基本输入处理
- 播放声音效果
这些概念的示例也可以在LibGDX.info中找到
项目搭建
按照“Project Setup Gradle”中的步骤进行操作。我使用以下配置:
Application name: drop
Package name: com.badlogic.drop
Game class: Drop
我还设置了项目的目标存储路径及SDK,我还取消选中OS子项目(这个可以根据您的需要自行选择),并取消了所有的扩展包(以为该项目不会用到这些扩展包,并且也是为了让示例项目足够简单)。
一旦导入到您的IDE中,您应该有5个项目或模块:main,以及子项目android(或Eclipse下的drop-android),核心/ drop-core,桌面/ drop-desktop和html /HTML。
要启动或调试游戏,请参阅Project Setup Gradle中专用于IDE的页面。
如果我们只是运行它,你会得到一个错误:无法加载文件:badlogic.jpg。 您必须首先编辑您的运行配置:选择工作目录PATH_TO_YOUR_PROJECT \ drop \ android \ assets,
如果我们现在运行,我们将获得由启动应用程序生成的默认“游戏”:在红色背景上的BadLogic Games图像.
游戏设计
游戏理念很简单:
- 用水桶捕捉雨滴。
- 桶位于屏幕的下部
- 雨滴每秒钟随机产生屏幕顶部,并向下加速。
- 玩家可以通过鼠标/触摸水平拖动水桶,或者通过左右光标键移动水桶。
- 当然我们并没有给游戏设置结束条件
游戏资源文件
我们需要几张图像和声音效果,使游戏看起来更加漂亮。对于图形,我们需要定义800x480像素的目标分辨率(Android上的横屏模式)。如果游戏运行的设备没有该分辨率,我们只需将所有内容缩放到屏幕上即可。注意:对于更高级的游戏项目,您可能需要考虑为不同的屏幕密度提供不同的资源。但这个一个独立的大论题,这里不再赘述。
雨滴和水桶应垂直占据屏幕的小部分,所以我们让它们的尺寸为64x64像素。
我从以下来源取得游戏资源:
water drop sound by junggle, see http://www.freesound.org/people/junggle/sounds/30341/
rain by acclivity, see http://www.freesound.org/people/acclivity/sounds/28283/
droplet sprite by mvdv, see https://www.box.com/s/peqrdkwjl6guhpm48nit
bucket sprite by mvdv, see https://www.box.com/s/605bvdlwuqubtutbyf4x
要使游戏资源可用于游戏,我们必须将它们放在Android资产文件夹中。 我命名了4个文件:drop.wav,rain.mp3,drops.png和bucket.png,并将它们放在android / assets /中。 我们只需要存储游戏资源一次,因为桌面和HTML5项目都配置为通过不同的方式“查看”此文件夹。 之后,根据您的IDE,您可能需要刷新项目树以使新文件已知(在Eclipse中,右键单击 - >刷新),否则可能会收到“未找到文件”的运行时异常。
配置启动类
根据我们的要求,我们现在可以配置不同的启动类。 我们将从桌面项目开始。 在desktop/ src / ...(或Eclipse下的drop-desktop)中打开DesktopLauncher.java类。 我们想要一个800x480的窗口,并将标题设置为“Drop”。 代码应该如下所示:
package com.badlogic.drop.desktop;
import com.badlogic.gdx.backends.lwjgl.LwjglApplication;
import com.badlogic.gdx.backends.lwjgl.LwjglApplicationConfiguration;
import com.badlogic.drop.Drop;
public class DesktopLauncher {
public static void main (String[] arg) {
LwjglApplicationConfiguration config = new LwjglApplicationConfiguration();
config.title = "Drop";
config.width = 800;
config.height = 480;
new LwjglApplication(new Drop(), config);
}
}
转到Android项目,我们希望应用程序以横屏模式运行。 为此,我们需要修改android(或drop-android)根目录中的AndroidManifest.xml,如下所示:
IDE已经为我们填写了正确的值,android:screenOrientation设置为“landscape”。 如果我们想以纵向模式运行游戏,我们将把该属性设置为“portrait”。
我们也想节约电池,禁用加速度计和指南针。 我们在android/src/...(或drop-android)中的AndroidLauncher.java文件中执行此操作,应该如下所示:
package com.badlogic.drop.android;
import android.os.Bundle;
import com.badlogic.gdx.backends.android.AndroidApplication;
import com.badlogic.gdx.backends.android.AndroidApplicationConfiguration;
import com.badlogic.drop.Drop;
public class AndroidLauncher extends AndroidApplication {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
AndroidApplicationConfiguration config = new AndroidApplicationConfiguration();
config.useAccelerometer = false;
config.useCompass = false;
initialize(new Drop(), config);
}
}
我们无法定义Activity的分辨率,因为它是由Android操作系统设置的。 如前所述,我们将简单地将800x480目标分辨率缩放到设备的分辨率。
关于代码
我们想将我们的代码分成几个部分。 为了简单起见,我们将所有内容保存在Core项目的Drop.java文件中,该文件位于core / src / ...(或Eclipse中的drop-core)中。
加载资源文件
我们的第一个任务是加载资产并存储对它们的引用。 资源通常在ApplicationAdapter.create()方法中加载,所以我们来做:
package com.badlogic.drop;
import com.badlogic.gdx.ApplicationAdapter;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.audio.Music;
import com.badlogic.gdx.audio.Sound;
import com.badlogic.gdx.graphics.Texture;
public class Drop extends ApplicationAdapter {
private Texture dropImage;
private Texture bucketImage;
private Sound dropSound;
private Music rainMusic;
@Override
public void create() {
// load the images for the droplet and the bucket, 64x64 pixels each
dropImage = new Texture(Gdx.files.internal("droplet.png"));
bucketImage = new Texture(Gdx.files.internal("bucket.png"));
// load the drop sound effect and the rain background "music"
dropSound = Gdx.audio.newSound(Gdx.files.internal("drop.wav"));
rainMusic = Gdx.audio.newMusic(Gdx.files.internal("rain.mp3"));
// start the playback of the background music immediately
rainMusic.setLooping(true);
rainMusic.play();
... more to come ...
}
// rest of class omitted for clarity
对于每个资源文件,在Drop.java中都有对应的变量指向它,这样我们就可以在后期随时随地使用它。
create()方法中的前两行加载雨滴和桶的图像。纹理表示存储在Video Ram中的图像,通常传递一个来自Assets的资源文件的FileHandle句柄到Texture 来创建一个纹理,FileHandle 实例是通过Gdx.files提供的方法之一获得的。我们使用【internal】来获得Assets文件夹下的资源文件,internal 文件位于Android项目的 assets 目录中。 如前所述,桌面和HTML5项目引用同一目录。
接下来我们加载声音效果和背景音乐。Music 通常比较大。根据经验,如果您的声音资源文件短于10秒,您应该使用Sound实例,而更长音频片段则使用Music实例。
声音或音乐实例的加载是通过Gdx.audio.newSound()和Gdx.audio.newMusic()完成的。 这两种方法都采用FileHandle,就像Texture构造函数一样。
相机和SpriteBatch
接下来,我们要创建一个摄像头和一个SpriteBatch。 我们将使用前者(Camera)来确保我们可以使用800x480像素的目标分辨率渲染,无论实际的屏幕分辨率如何。 SpriteBatch是一个特殊的类,用于绘制2D图像,像加载我们的纹理。
我们在类上添加两个新的字段,我们称之为相机和batch:
private OrthographicCamera camera;
private SpriteBatch batch;
在create()方法中,我们这样初始化相机:
camera = new OrthographicCamera();
camera.setToOrtho(false, 800, 480);
这将确保相机总是向我们展示我们的游戏世界的800x480单位宽的区域。 把它看作是我们世界的虚拟窗户。 我们目前将单位默认 为像素。 没有什么可以阻止我们使用其他单位, 米或任何你可以想得到的单位。 相机非常强大,您可以在本基础教程中做很多事情。查看开发者指南的其余部分了解更多信息。
接下来我们仍在create()方法中创建SpriteBatch实例:
batch = new SpriteBatch();
至此我们差不多已经完成了创建我们需要运行这个简单的游戏的所有事情。
添加桶
接下来我们来添加我们的桶还有雨滴:
- 一个桶/雨滴在我们的800x480单位世界的x / y位置。
- 一个桶/雨滴的宽度和高度以我们世界的单位表示。
- 桶/雨滴具有图形表示,我们已经具有我们加载的Texture实例的形式。
所以,要描述桶和雨滴,我们需要存储他们的位置和大小。 Libgdx提供了一个可以用于此目的的Rectangle类。 我们首先创建一个代表我们的存储桶的Rectangle类。 我们添加一个新字段:
private Rectangle bucket;
在create() 方法中我们实例化了一个Rectangle对象并为它设置了初始值,我们希望桶在离屏幕底部边缘20像素的上方,并且水平居中:
bucket = new Rectangle();
bucket.x = 800 / 2 - 64 / 2;
bucket.y = 20;
bucket.width = 64;
bucket.height = 64;
我们将水桶放在水平方向上,并将其放置在屏幕底部的20个像素上方。 等等,为什么bucket.y设置为20,不应该是480 - 20?这是因为LibGDX采用的是笛卡尔坐标系(以左下角为原点)。
注意:可以更改此设置相关讨论,以便y轴指向下,原点位于屏幕的左上角。 OpenGL和相机类非常灵活,您可以在2D和3D中拥有您想要的任何种类的视角。 我们将继续使用上述设置。
渲染桶
是时候来绘制我们的桶了。我们要做的第一件事是用深蓝色来清除屏幕。 只需将render()方法更改为如下所示:
@Override
public void render() {
Gdx.gl.glClearColor(0, 0, 0.2f, 1);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
... more to come here ...
}
如果您使用高级类(如Texture或SpriteBatch),这两行就是您需要了解OpenGL的唯一内容。 第一个调用会将清屏的颜色设置为蓝色。 参数是该颜色的红色,绿色,蓝色和alpha值,分别在[0,1]范围内。 下一个调用指示OpenGL实际清除屏幕。
接下来,我们需要告诉我们的相机,以确保实时更新。 相机使用称为矩阵的数学实体,负责设置渲染坐标系。 每当我们更改相机的属性(如位置)时,相机都需要重新计算这些矩阵。 我们不会在简单的例子中做到这一点,但是通常每帧更新一次摄像机一般是一个很好的做法:
camera.update();
现在我们可以渲染我们的桶了:
batch.setProjectionMatrix(camera.combined);
batch.begin();
batch.draw(bucketImage, bucket.x, bucket.y);
batch.end();
第一行告诉SpriteBatch使用相机指定的坐标系。 如前所述,这是通过称为矩阵的东西来完成的,更具体地说,是一个投影矩阵。 camera.combined字段是这样一个矩阵。 从SpriteBatch上将会在前面描述的坐标系中呈现所有内容。
接下来我们告诉SpriteBatch开始一个新批量渲染。 为什么我们需要这个,什么是批量渲染? OpenGL讨厌不高效的绘制渲染。 它希望被告知有关尽可能多的图像需要渲染,并一次性渲染完毕。
SpriteBatch 使得 OpenGL 在渲染视图上更加得心应手,它将记录SpriteBatch.begin()和SpriteBatch.end()之间的所有绘图命令。 一旦我们调用SpriteBatch.end(),它将一次提交我们所做的所有绘图请求,加快渲染速度。 这一切可能在一开始就看起来很麻烦,但是它使得渲染500个精灵之间的差异是每秒60帧,并以每秒20帧的速度渲染100个精灵。
使水桶移动(触摸/鼠标)
时间让用户控制桶。 之前我们说过我们允许用户拖动存储桶。 让我们让事情变得更容易一些。 如果用户触摸屏幕(或按下鼠标按钮),我们希望水桶将水平放置在该位置的周围。 将以下代码添加到render()方法的底部将这样做:
if(Gdx.input.isTouched()) {
Vector3 touchPos = new Vector3();
touchPos.set(Gdx.input.getX(), Gdx.input.getY(), 0);
camera.unproject(touchPos);
bucket.x = touchPos.x - 64 / 2;
}
解析:
- 首先,我们通过调用Gdx.input.isTouched()来访问输入模块当前是否触摸屏幕(或按下鼠标按钮)。 接下来,我们要将触摸/鼠标坐标转换为我们的相机的坐标系。 这是必要的,因为触摸/鼠标坐标的坐标系可能与我们用于表示我们世界中的对象的坐标系不同。
Gdx.input.getX()和Gdx.input.getY()返回当前的触摸/鼠标位置(libgdx也支持多点触控,但这是另一篇文章的主题)。 要将这些坐标转换到我们的摄像机坐标系,我们需要调用camera.unproject()方法,它需要一个三维矢量Vector3作为参数。 我们创建这样一个向量,设置当前的触摸/鼠标坐标并调用方法。 矢量现在将包含我们的桶所在坐标系中的触摸/鼠标坐标。最后,我们改变桶的位置,以触摸/鼠标坐标为中心。
注意:总是实例化一个新的对象,如Vector3实例这是一个非常糟糕的做法。 因为垃圾收集器必须频繁地收集这些短命的变量。在桌面应用上可能还好,但是在Android上,GC可能会导致停留时间达几百毫秒,从而导致卡顿。 为了在这种特殊情况下解决这个问题,我们可以简单地让TouchPos成为Drop类的一个全局变量,而不是一直实例化它。
touchPos是一个三维向量。 你可能会想知道为什么我们只用2D操作但是使用一个三维向量。 OrthographicCamera实际上是一个3D相机,也考虑到z坐标。 想想CAD应用程序,他们也使用3D摄相机。 我们只是滥用它来绘制2D图形。
使桶移动(键盘)
在桌面和浏览器中,我们也可以接收键盘输入。 当按下左或右光标键时,让桶移动。
我们希望桶匀速移动,以每秒200像素/单位向左或向右移动。 为了实现这种基于时间的运动,我们需要知道在最后和当前渲染帧之间的时间间隔。 以下是我们如何做到这一点:
if(Gdx.input.isKeyPressed(Input.Keys.LEFT)) bucket.x -= 200 * Gdx.graphics.getDeltaTime();
if(Gdx.input.isKeyPressed(Input.Keys.RIGHT)) bucket.x += 200 * Gdx.graphics.getDeltaTime();
Gdx.input.isKeyPressed()告诉我们是否按下了特定的键。 键枚举包含libgdx支持的所有键码。 方法Gdx.graphics.getDeltaTime()返回在最后和当前帧之间的时间间隔(以秒为单位)。 我们所需要做的就是通过以秒为单位增加/减去200像素来修改桶的x坐标。
我们还需要确保我们的桶保持在屏幕的可视范围内:
if(bucket.x < 0) bucket.x = 0;
if(bucket.x > 800 - 64) bucket.x = 800 - 64;
添加雨滴
对于雨滴,我们设计了一个Rectangle 列表实例,每个实例都跟踪雨滴的位置和大小。 我们将该列表添加为一个字段:
private Array raindrops;
Array类是一个libgdx实用程序类,而不是像ArrayList这样的标准Java集合。 后者的问题是以各种方式生产垃圾。 Array类尝试尽量减少垃圾。 Libgdx还提供其他垃圾收集器感知集合,如散列图或集合。
我们还需要记录上次我们产生的雨滴的时间,所以我们添加另一个字段:
private long lastDropTime;
我们将把时间精确到纳秒,这就是为什么我们使用了long 类型的原因。
为了方便创建雨滴,我们将编写一个名为spawnRaindrop()的方法,该方法实例化一个新的Rectangle,将其设置为屏幕顶部边缘的随机位置,并将其添加到雨滴阵列中。
private void spawnRaindrop() {
Rectangle raindrop = new Rectangle();
raindrop.x = MathUtils.random(0, 800-64);
raindrop.y = 480;
raindrop.width = 64;
raindrop.height = 64;
raindrops.add(raindrop);
lastDropTime = TimeUtils.nanoTime();
}
该方法的目的和明确。 MathUtils类是一个提供各种数学相关静态方法的libgdx类。 在这种情况下,它将返回0到(800-64)之间的随机值.TimeUtils是另一个libgdx类,提供了一些非常基本的时间相关的静态方法。 在这种情况下,我们以纳秒为单位记录当前时间,以后我们决定是否产生新的雨滴。
在create()方法中,我们实例化了雨滴数组,并产生了我们的第一个雨滴:
我们需要在create()方法中实例化该数组:
raindrops = new Array();
spawnRaindrop();
接下来,我们在render()方法中添加几行,该方法将检查自从我们产生一个新的雨滴以来已经过去了多少时间,并在必要时创建一个新的雨滴:
if(TimeUtils.nanoTime() - lastDropTime > 1000000000) spawnRaindrop();
我们还需要使我们的雨滴移动,让我们采取简单的做法,让他们以每秒200像素/秒的速度移动。 如果雨滴在屏幕底部下方,我们将其从阵列中移除。
Iterator iter = raindrops.iterator();
while(iter.hasNext()) {
Rectangle raindrop = iter.next();
raindrop.y -= 200 * Gdx.graphics.getDeltaTime();
if(raindrop.y + 64 < 0) iter.remove();
}
····
雨滴需要渲染。 我们将添加到现在的SpriteBatch:
````java
batch.begin();
batch.draw(bucketImage, bucket.x, bucket.y);
for(Rectangle raindrop: raindrops) {
batch.draw(dropImage, raindrop.x, raindrop.y);
}
batch.end();
一个最后的调整:如果一个雨滴撞到水桶,我们想播放我们的下落声音,并从阵列中删除雨滴。 我们只需将以下行添加到雨滴更新循环中:
if(raindrop.overlaps(bucket)) {
dropSound.play();
iter.remove();
}
Rectangle.overlaps()方法检查这个矩形是否与另一个矩形重叠/碰撞。 在我们的情况下,我们将播放声音效果,并从阵列中删除雨滴。
释放
用户可以随时关闭应用程序。 对于这个简单的例子,没有什么需要做的。 然而一般来说,帮助操作系统清理我们创建的垃圾是一个好主意。
任何实现Disposable接口的libgdx类,都具有dispose()方法,在不再使用后需要手动进行清理。 在我们的例子中,纹理,声音和音乐以及SpriteBatch都是如此。 作为好的程序员,我们复写ApplicationAdapter.dispose()方法如下:
@Override
public void dispose() {
dropImage.dispose();
bucketImage.dispose();
dropSound.dispose();
rainMusic.dispose();
batch.dispose();
}
处理资源后,您不应该以任何方式访问它。
Disposables (本机资源)通常是无法被Java垃圾收集器处理的。 这就是为什么我们需要手工处理它们的原因。 Libgdx提供了各种帮助资产管理的方式。 阅读开发指南的其余部分来发现它们。
处理暂停/恢复
每当用户打电话或按住主页按钮时,Android都会暂停和恢复您的应用程序。 在这种情况下,Libgdx会为您自动执行许多操作,例如 重新加载可能已经丢失的图像(OpenGL上下文丢失,一个可怕的主题),暂停和恢复音乐流等。
在我们的游戏中,实际上不需要处理暂停/恢复。 一旦用户回到应用程序,游戏就会继续下去。 通常会实现暂停屏幕,并要求用户触摸屏幕以继续。 这是为读者留下的一个练习 - 查看ApplicationAdapter.pause()和ApplicationAdapter.resume()方法。
完整代码
这是我们这个示例游戏的完整代码:
package com.badlogic.drop;
import java.util.Iterator;
import com.badlogic.gdx.ApplicationAdapter;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input.Keys;
import com.badlogic.gdx.audio.Music;
import com.badlogic.gdx.audio.Sound;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Rectangle;
import com.badlogic.gdx.math.Vector3;
import com.badlogic.gdx.utils.Array;
import com.badlogic.gdx.utils.TimeUtils;
public class Drop extends ApplicationAdapter {
private Texture dropImage;
private Texture bucketImage;
private Sound dropSound;
private Music rainMusic;
private SpriteBatch batch;
private OrthographicCamera camera;
private Rectangle bucket;
private Array raindrops;
private long lastDropTime;
@Override
public void create() {
// load the images for the droplet and the bucket, 64x64 pixels each
dropImage = new Texture(Gdx.files.internal("droplet.png"));
bucketImage = new Texture(Gdx.files.internal("bucket.png"));
// load the drop sound effect and the rain background "music"
dropSound = Gdx.audio.newSound(Gdx.files.internal("drop.wav"));
rainMusic = Gdx.audio.newMusic(Gdx.files.internal("rain.mp3"));
// start the playback of the background music immediately
rainMusic.setLooping(true);
rainMusic.play();
// create the camera and the SpriteBatch
camera = new OrthographicCamera();
camera.setToOrtho(false, 800, 480);
batch = new SpriteBatch();
// create a Rectangle to logically represent the bucket
bucket = new Rectangle();
bucket.x = 800 / 2 - 64 / 2; // center the bucket horizontally
bucket.y = 20; // bottom left corner of the bucket is 20 pixels above the bottom screen edge
bucket.width = 64;
bucket.height = 64;
// create the raindrops array and spawn the first raindrop
raindrops = new Array();
spawnRaindrop();
}
private void spawnRaindrop() {
Rectangle raindrop = new Rectangle();
raindrop.x = MathUtils.random(0, 800-64);
raindrop.y = 480;
raindrop.width = 64;
raindrop.height = 64;
raindrops.add(raindrop);
lastDropTime = TimeUtils.nanoTime();
}
@Override
public void render() {
// clear the screen with a dark blue color. The
// arguments to glClearColor are the red, green
// blue and alpha component in the range [0,1]
// of the color to be used to clear the screen.
Gdx.gl.glClearColor(0, 0, 0.2f, 1);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
// tell the camera to update its matrices.
camera.update();
// tell the SpriteBatch to render in the
// coordinate system specified by the camera.
batch.setProjectionMatrix(camera.combined);
// begin a new batch and draw the bucket and
// all drops
batch.begin();
batch.draw(bucketImage, bucket.x, bucket.y);
for(Rectangle raindrop: raindrops) {
batch.draw(dropImage, raindrop.x, raindrop.y);
}
batch.end();
// process user input
if(Gdx.input.isTouched()) {
Vector3 touchPos = new Vector3();
touchPos.set(Gdx.input.getX(), Gdx.input.getY(), 0);
camera.unproject(touchPos);
bucket.x = touchPos.x - 64 / 2;
}
if(Gdx.input.isKeyPressed(Keys.LEFT)) bucket.x -= 200 * Gdx.graphics.getDeltaTime();
if(Gdx.input.isKeyPressed(Keys.RIGHT)) bucket.x += 200 * Gdx.graphics.getDeltaTime();
// make sure the bucket stays within the screen bounds
if(bucket.x < 0) bucket.x = 0;
if(bucket.x > 800 - 64) bucket.x = 800 - 64;
// check if we need to create a new raindrop
if(TimeUtils.nanoTime() - lastDropTime > 1000000000) spawnRaindrop();
// move the raindrops, remove any that are beneath the bottom edge of
// the screen or that hit the bucket. In the later case we play back
// a sound effect as well.
Iterator iter = raindrops.iterator();
while(iter.hasNext()) {
Rectangle raindrop = iter.next();
raindrop.y -= 200 * Gdx.graphics.getDeltaTime();
if(raindrop.y + 64 < 0) iter.remove();
if(raindrop.overlaps(bucket)) {
dropSound.play();
iter.remove();
}
}
}
@Override
public void dispose() {
// dispose of all the native resources
dropImage.dispose();
bucketImage.dispose();
dropSound.dispose();
rainMusic.dispose();
batch.dispose();
}
}
这是一个非常基本的例子,介绍如何使用libgdx创建一个简单的游戏。 有些事情可以改进,像使用内存管理类来回收我们每次删除雨滴时垃圾收集器清理的所有矩形。 如果我们在批量中传递太多不同的图像,OpenGL也不太喜欢(在我们这种情况下,我们只有两个图像情有可原)。 通常会把所有这些图像都放在一个单一的纹理中,也称为TextureAtlas。 屏幕和游戏也可以用于增加交互; 要了解更多,请看进阶教程。
我强烈建议您阅读开发人员指南的其余部分,并查看Git存储库中的演示和测试。