Libgdx Developer's Guide(Libgdx开发者手册)-8(一个简单的游戏)

在潜入libgdx提供的API之前,让我们创建一个非常简单的“游戏”,这个游戏每个模块都将触及一点以让我们有种整体感觉。我们会引入一些概念,但不会太深入。

我们来看看:
  • 基本文件访问
  • 清屏
  • 绘图
  • 使用照相机
  • 基本输入处理
  • 播放音效

项目设置

按照 Project Setup中的步骤进行。使用以下名称:

  • 应用程序名: drop
  • 包名: com.badlogic.drop
  • 游戏类: Drop

一旦导入到Eclipse中,你应该拥有4个工程: drop, drop-android, drop-desktop 和 drop-html5。

游戏

游戏的想法很简单:

  • 用水桶捕捉雨滴
  • 水桶位于屏幕下方
  • 雨滴每秒随机从屏幕顶部产生并加速落下
  • 玩家可以通过鼠标/触屏或者左右键来水平移动水桶
  • 游戏没有结尾,想像它是一禅宗般的体验 :)

资源

我们需要一些图片和音效来使游戏看起来稍微漂亮一些。对于图形,定义目标分辨率为800x480像素(Android横屏)。如果游戏运行的设备没有该分辨率,则缩放所有东西以适应屏幕。注意:对于 高质量的游戏,你可能考虑会不同的屏幕分辨率提供不同的资源。这本身是一个大话题,这里不深入。

雨滴和水桶在垂直方向占用的屏幕应该小于十分之一,因此定义它们的大小为64x64像素。

从以下地址获取资源:

为了让游戏可以使用这些资源,必须把它们放在Android工程的assets文件夹下。把这4个文件命名为:drop.wav, rain.mp3, droplet.png 和 bucket.png,并把它们放在 drop-android/assets/ 文件夹下。桌面应用和HTML5工程都链接至该资源文件夹,因此只需要存储一次。

配置启动类

鉴于我们的需求,我们现在开始配置不同的启动类。先从桌面应用开始。打开drop-desktop/下的类Main.java。我们需要一个 800x480 的窗口并设置标题为"Drop"。代码如下:

package com.badlogic.drop;

import com.badlogic.gdx.backends.lwjgl.LwjglApplication;
import com.badlogic.gdx.backends.lwjgl.LwjglApplicationConfiguration;

public class Main {
   public static void main(String[] args) {
      LwjglApplicationConfiguration cfg = new LwjglApplicationConfiguration();
      cfg.title = "Drop";
      cfg.width = 800;
      cfg.height = 480;
      new LwjglApplication(new Drop(), cfg);
   }
}

转到Android工程,我们想要应用在横屏运行。因此需要修改工程根目录下的AndroidManifest.xml,如下: 

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.badlogic.drop"
    android:versionCode="1"
    android:versionName="1.0" >

    <uses-sdk android:minSdkVersion="5" android:targetSdkVersion="15" />

    <application
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name" >
        <activity
            android:name=".MainActivity"
            android:label="@string/app_name"
            android:screenOrientation="landscape"
            android:configChanges="keyboard|keyboardHidden|orientation">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

配置工具已经为我们填写了正确的值,android:screenOrientation设置为"landscape"。如果要在竖屏模式运行游戏,就把该属性设置为 "portrait"。

我们希望节省电池并禁用振动器和指南针。这需要在工程的MainActivity.java中做以下修改:


package com.badlogic.drop;

import android.os.Bundle;

import com.badlogic.gdx.backends.android.AndroidApplication;
import com.badlogic.gdx.backends.android.AndroidApplicationConfiguration;

public class MainActivity extends AndroidApplication {
   @Override
   public void onCreate(Bundle savedInstanceState) {
      super.onCreate(savedInstanceState);
        
      AndroidApplicationConfiguration cfg = new AndroidApplicationConfiguration();
      cfg.useAccelerometer = false;
      cfg.useCompass = false;
        
      initialize(new Drop(), cfg);
   }
}

我们不能定义Activity的分辨率,这是由Android操作系统设置的。像之前设置的一样,无论设备分辨率是多少,我们会缩放800x480的目标分辨率到当前设备分辨率。

最后,我们要确保HTML5工程也使用一个800x480的绘制区。因此需要修改html5工程下的GwtLauncher.java文件:

package com.badlogic.drop.client;

import com.badlogic.drop.Drop;

public class GwtLauncher extends GwtApplication {
   @Override
   public GwtApplicationConfiguration getConfig () {
      GwtApplicationConfiguration cfg = new GwtApplicationConfiguration(800, 480);
      return cfg;
   }

   @Override
   public ApplicationListener getApplicationListener () {
      return new Drop();
   }
}

注意:  我们不需要为该平台指定使用的OpenGL版本,因为它只支持2.0。

现在所有的启动类都配置完成了,让我们开始实现这个有趣的游戏。

代码

我们希望把代码分成几部分。简单起见,我们把所有东西都放在核心工程的Drop.java文件中。 

载入资源

第一个任务是加载资源并储存它们的引用。通常在ApplicationListener.create()方法中加载资源,因此代码如下: 

public class Drop implements ApplicationListener {
   Texture dropImage;
   Texture bucketImage;
   Sound dropSound;
   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类中拥有一个字段,因而后续我们可以引用它。create()方法的前两行加载雨滴和水桶的图片。Texture表示一个存储于视频RAM里的已加载图片。通常不直接绘制Texture。 Texture通过向其构造器传入一个资源文件的FileHandle来加载。这种FileHandle的实例是通过byGdx.files里其中一个方法来获得的。不同的文件类型有很多,我们在这里使用 "internal" 文件类型来引用资源。Internal 的文件位于Android工程的assets目录中。Eclipse中,桌面应用和HTML5工程通过链接以引用该目录。

接下来加载音效与背景音乐。Libgdx 区分音效和音乐,音效存储在内存里,音乐无论存储在哪都会被转换为流。音乐通常太大不能完全存储在内存里,因此作此区分。根据经验,如果你的示例小于10秒则要使用一个Sound实例,更长的音频就要使用Music实例。

通过Gdx.app.newSound()Gdx.app.newMusic()来加载SoundMusic。这两个方法都需要一个FileHandle,像Texture的构造器一样。

create()方法末尾,我们让Music实例循环并立即播放。 如果你运行这个应用,你会看到一个漂亮的粉红色背景,能听到落雨声。


Camera 和 SpriteBatch

接下来我们创建 一个Camera 和 SpriteBatch。我们使用前者以保证使用目标分辨率800x480像素来呈现应用,而不管实际的分辨率是多少。SpriteBatch 是一个特殊的类用来绘制2D图形,比如我们已经加载的纹理。

我们向类中加入两个新字段,命名为 camera  和 batch:

   
OrthographicCamera camera;
   SpriteBatch batch;

create() 方法中我们首先这样创建 camera :

   camera = new OrthographicCamera();
   camera.setToOrtho(false, 800, 480);

这样就可以确保camera一直为我们展示一个800x480单位宽的游戏区。想象它是一个虚拟窗口。目前我们把像素作为单位,这样简单一些。使用其它单位也没什么,如meters或其他什么。Cameras非常强大,它能作很多事,我们在此基础手册中不再详述。查看剩下的用户手册来获取更多信息。

然后创建 SpriteBatch (仍然在 create()方法中):

 
  batch = new SpriteBatch();

通过创建这些,我们差不多已经完成所有运行游戏所需要的东西。

加入水桶

最后缺少的水桶和雨滴。让我们想想要用代表描述什么:

  • 一个有x/y坐标的水桶/雨滴在800x480大小的空间。
  • 在游戏区表示出水桶/雨滴的宽高。
  • 水桶/雨滴的图形表示,我们已经通过Texture实例加载过了。 

因此,为了描述水桶与雨滴,我们需要保存它们的位置和大小。Libgdx提供一个Rectangle类可以达成这个目的。开始先创建一个表示水桶的Rectangle。添加一个新字段:

   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(与OpenGL)中显示的东西其y轴都指向上方。水桶的x/y坐标定义在水桶的左下角,绘图的原点位于屏幕左下角。矩形的宽高设置为64x64,小于目标分辨率高度的十分之一。

注意: 可以 更改配置 使y轴向下并且原点在屏幕左上角。 OpenGL 和 camera 类非常灵活,你可以在2D和3D下使用几乎任何一种视角。

渲染水桶

是时候渲染水桶了。首先要做的是用深蓝色清屏。更改render() 方法如下:

   @Override
   public void render() {
      Gdx.gl.glClearColor(0, 0, 0.2f, 1);
      Gdx.gl.glClear(GL10.GL_COLOR_BUFFER_BIT);

      ... more to come here ...
   }

如果你使用高级类如Texture 或 SpriteBatch,那关于OpenGL你只需要知道这两行。第一个调用把清屏色设置为蓝色。其参数分别是红,绿,蓝和该颜色的透明度,每个的取值范围都是[0, 1]。下一个调用命令OpenGL直接去清屏。

然后调用camera去更新。Cameras 使用一个称作矩阵的数学实体负责建立渲染的坐标系。每次更改camera属性都要重新计算这些矩阵。我们不在这个简单的例子中做这些,但每帧更新一次camera是一个很好的实践:


  
 camera.update();

现在可以显示水桶了:

   batch.setProjectionMatrix(camera.combined);
   batch.begin();
   batch.draw(bucketImage, bucket.x, bucket.y);
   batch.end();

第一行告诉 SpriteBatch 使用camera指定的坐标系。如前所述,这是由一种叫做矩阵的东西完成的,确切地说,叫投影矩阵。camera.combined字段就是这样一个矩阵。SpriteBatch将从那里在坐标系中渲染前面描述过的所有东西。

下面告诉 SpriteBatch 启动一个新的batch。为什么要这么做,batch又是什么?OpenGL 最讨厌只告诉它一个单独的图片,它希望一次性告诉它尽可能多的多个图片。

SpriteBatch 类就可以帮助 OpenGL 。它会记录SpriteBatch.begin() 和 SpriteBatch.end()之间的所有绘制命令。一旦调用SpriteBatch.end(),它会一次性把提交所有的绘画请求,这让渲染过程加速很多。刚开始这些或者看起来很烦,但正是这一点造成了每秒60帧显示500个sprite和每秒20帧显示100个sprite之间的差别。 

使水桶移动起来 (触屏/鼠标)

是时候让用户控制水桶了。之前我们提到过要让用户拖动水桶。让我们稍微简化一下。如果用户触摸屏幕(或按下鼠标),我们希望水桶围绕这一点水居中。在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()来查寻输入模块当前屏幕是否被触摸(或鼠标被按下)。接下来我们把触屏/鼠标人坐标转换到camera的坐标系。这很有必要,因为触屏/鼠标的坐标系很可能我我们用来显示对象的坐标系不一致。

Gdx.input.getX() 和 Gdx.input.getY() 返回当前 触摸/鼠标位置(libgdx也支持多点触控,但这是另一个话题了)。要把这些坐标转换到我们的camera的坐标系,需要调用camera.unproject() 方法,这需要一个Vector3, 一个三维矢量。创建这样的矢量,设置当前触摸/鼠标的坐标并调用该方法。该矢量就会包含水桶所在坐标系的触摸/鼠标的坐标。最后,我们更改水桶位置以围绕触摸/鼠标的坐标居中。

注意: 总是实例化新的对象是非常非常坏的一种方法,比如这里的Vector3对象。原因是垃圾回收器不得不频繁地清除这些短命的对象。在桌面应用中这不是一个大问题,但在Android里,垃圾回收器会导致几百毫秒的暂停因而会很卡。为了解决这个特殊问题,可以简单地将touchPos作为Drop类的一个字段,而不是总是实例化。

注意#2: touchPos 是一个三维矢量。你可能想知道为什么我们只操作2D时还需要它。OrthographicCamera实际上是3D camera,它也有z坐标。想想CAD应用,它们也使用3D正交camera。我们只是简单地用它来绘制2D图形。 

使水桶移动起来 (键盘)

在桌面和浏览器中,也可以接收键盘输入。当左右方向键被按下时,使水桶移动起来。

我们希望水桶移动时不振动,无论向左或向右,每秒200像素单位。要实现这种基于时间的移动,我们需要知道最近一帧和当前帧之间经过的时间。下面是相应的做法:

   if(Gdx.input.isKeyPressed(Keys.LEFT)) bucket.x -= 200 * Gdx.graphics.getDeltaTime();
   if(Gdx.input.isKeyPressed(Keys.RIGHT)) bucket.x += 200 * Gdx.graphics.getDeltaTime();

Gdx.input.isKeyPressed() 方法告诉我们特定的键是否被按下。枚举类Keys包含所有libgdx支持的键码。Gdx.graphics.getDeltaTime()方法返回最近上帧和当前帧之间所经历的秒数。我们只需修改水桶的x坐标,每次加上/减去 100单位。

同时还要保证水桶处于屏幕范围内。

 
  if(bucket.x < 0) bucket.x = 0;
   if(bucket.x > 800 - 64) bucket.x = 800 - 64;

添加雨滴

对雨滴来说,我们保存一个Rectangle实例列表,每一个用来跟踪一个雨滴的位置及大小。为这个列表加入一个字段:

   Array<Rectangle> raindrops;

Array 类是libgdx的一个公共类用来替代标准Java集合如ArrayList。后者的问题是很多情况下它会产生垃圾。Array类尝试尽可能多地减少垃圾。Libgdx提供其他垃圾回收器可以回收的集合,如hashmaps或sets等。

我们也需要保持跟踪产生雨滴的最后时间,因此我们添加另一个字段:


 
  long lastDropTime;

我们要用纳秒来存储这个时间,因此使用long类型。

为便于创建雨滴,我们写一个方法叫spawnRaindrop(),它实例化一个新的Rectangle,把它设置到屏幕顶部的一个随机位置,并添加到raindrops数组。

   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<Rectangle>();
   spawnRaindrop();

接下来在render()方法中添加几行,来检查自从产生一个新雨滴以来所经历的时间,如果需要的话再创建一个新雨滴:

   
if(TimeUtils.nanoTime() - lastDropTime > 1000000000) spawnRaindrop();

我们也需要让雨滴动起来,让我们采取简单的方法,让它们以每秒 200像素/单位 的恒定速度移动。如果雨滴位置屏幕底部以下,我们就从数组里移除它。

   Iterator<Rectangle> iter = raindrops.iterator();
   while(iter.hasNext()) {
      Rectangle raindrop = iter.next();
      raindrop.y -= 200 * Gdx.graphics.getDeltaTime();
      if(raindrop.y + 64 < 0) iter.remove();
   }

雨滴需要渲染。在SpriteBatch中添加渲染代码后如下:

   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类并且因此带有adispose()方法,都需要在不使用时手动销毁。在我们的例子中纹理,声音,音乐和SpriteBatch都是符合条件类。身为好市民,我们如下这样实现{ApplicationListener#dispose() 方法: 

   @Override
   public void dispose() {
      dropImage.dispose();
      bucketImage.dispose();
      dropSound.dispose();
      rainMusic.dispose();
      batch.dispose();
   }

一旦你销毁一个资源,就不可以在任何地方访问它。

可销毁资源通常是一些不能被Java的垃圾回收器处理的本地资源。这就是我们为什么要手动销毁的原因。Libgdx 提供丰富的方法来管理资源。读剩下的开发手册查看这些方法。

处理暂停/恢复

每次用户接到一个电话或按下home键时,Android都 有暂停和恢复应用程序的标记。Libgdx在这种情况下会自动为你做很多事情,比如:重新加载可能丢失的图片(OpenGL上下文丢失,对它而言是很严重的一个话题),暂停和恢复音乐流等。

我们的游戏其实不需要处理暂停和恢复。当用户回到应用时,游戏会接着上次离开时的状态继续运行。通常人们会实现一个暂停按钮让用户点击屏幕继续。这留给读者作为练习。查看下ApplicationListener.pause()ApplicationListener.resume()方法。

完整的源码

这是我们这个简单游戏的源码:

package com.badlogic.drop;

import java.util.Iterator;

import com.badlogic.gdx.ApplicationListener;
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.GL10;
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 implements ApplicationListener {
   Texture dropImage;
   Texture bucketImage;
   Sound dropSound;
   Music rainMusic;
   SpriteBatch batch;
   OrthographicCamera camera;
   Rectangle bucket;
   Array<Rectangle> raindrops;
   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<Rectangle>();
      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(GL10.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<Rectangle> 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();
   }

   @Override
   public void resize(int width, int height) {
   }

   @Override
   public void pause() {
   }

   @Override
   public void resume() {
   }
}

接下来怎么走

这是一个非常基础的例子,展示怎样用Libgdx创建一个简易的游戏。一些东西还可以再改进,比如使用Pool类来循环使用Rectangles,我们在删除雨滴时让垃圾回收器都把它们回收了。如果一个批处理中给它许多不同图片,OpenGL就不好用。在我们的例子中没问题,因为我们只有两个图片。通常我们会把所有不同图片放在单个Texture里,也被称作TextureAtlas。 

我强烈推荐你读剩下的开发手册,并检出Git仓库里的demo和测试。编程快乐。

猜你喜欢

转载自blog.csdn.net/bob007abc/article/details/9358179
今日推荐