通用的游戏开发框架


从本章开始,我们将讲解一个来自于github上的游戏项目,由于该项目使用的LibGDX版本比较老旧,于是我在本次重建项目的过程中利用了许多LibGDX 1.20版的新特性。

1. 创建游戏主类Linkgems

package com.art.zok.linkgems;

import com.badlogic.gdx.Game;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;

public class Linkgems extends Game {
    private OrthographicCamera _camera;
    private SpriteBatch _batch;

    @Override
    public void create() {        
        _batch = new SpriteBatch();
        _camera = new OrthographicCamera(1280, 720);
        _camera.setToOrtho(true, 1280, 720);
        _camera.update();
    }

    @Override
    public void render() {    
        Gdx.gl.glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
        Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);

        _batch.setProjectionMatrix(_camera.combined);
        _batch.begin();
        _batch.end();
    }

    @Override
    public void dispose() {
        super.dispose();
        _batch.dispose();
    }

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

    @Override
    public void pause() {
        super.pause();
    }

    @Override
    public void resume() {
        super.resume();
    }
}

首先,我们可以看到该类继承于Game类,这表明该类具有多屏管理的能力。稍后我们将实现一个简单的多屏管理。

因为本项目包含三个界面,为了更紧凑的管理屏幕的切换观察,我们打算创建一个通用屏幕切换方法,这样做还有一点好处是,将来可以添加更多的通用效果。

2. 创建一个所有屏幕都应该继承的抽象类

package com.art.zok.linkgems.screen;
import com.art.zok.linkgems.Linkgems;
import com.badlogic.gdx.InputProcessor;
import com.badlogic.gdx.Screen;
public abstract class AbstractScreen implements Screen, InputProcessor {
    protected Linkgems _parent; 
    public AbstractScreen(Linkgems linkgems) {
        this._parent = linkgems;
    }
    @Override
    public abstract void render(float delta);
    @Override
    public abstract void resize(int width, int height);
    @Override
    public abstract void show();
    @Override
    public abstract void hide();
    @Override
    public abstract void pause();
    @Override
    public abstract void resume();
    @Override
    public abstract void dispose();
    @Override
    public boolean keyDown(int keycode) {
        return false;
    }
    @Override
    public boolean keyUp(int keycode) {
        return false;
    }
    @Override
    public boolean keyTyped(char character) {
        return false;
    }
    @Override
    public boolean touchDown(int screenX, int screenY, int pointer, int button) {
        return false;
    }
    @Override
    public boolean touchUp(int screenX, int screenY, int pointer, int button) {
        return false;
    }
    @Override
    public boolean touchDragged(int screenX, int screenY, int pointer) {
        return false;
    }
    @Override
    public boolean mouseMoved(int screenX, int screenY) {
        return false;
    }
    @Override
    public boolean scrolled(int amount) {
        return false;
    }
}

为什么要创建该类,而不是直接使用Screen类呢?因为我们希望在该类中完成所有屏幕切换的通用过程,比如切屏动画、资源的加载与释放、游戏状态的重置等等一系列过程。我们还为该类实现了InputProcessor接口,这表明每个屏幕类也将是一个输入处理器,还有,我们覆盖了输入处理的每个方法是为了避免子类在继承该类时,强制实现所有方法,这也是我们经常会碰到的适配器模式。

3. 接下来创建三个游戏屏幕类

菜单屏幕类

package com.art.zok.linkgems.screen;
import com.art.zok.linkgems.Linkgems;
public class MenuScreen extends AbstractScreen {
    public MenuScreen(Linkgems linkgems) {
        super(linkgems);
    }
    @Override
    public void render(float delta) {
    }
    @Override
    public void resize(int width, int height) {
    }
    @Override
    public void show() {
    }
    @Override
    public void hide() {
    }
    @Override
    public void pause() {
    }
    @Override
    public void resume() {
    }
    @Override
    public void dispose() {
    }
}

游戏屏幕类

package com.art.zok.linkgems.screen;
import com.art.zok.linkgems.Linkgems;
public class GameScreen extends AbstractScreen {
    public GameScreen(Linkgems linkgems) {
        super(linkgems);
    }
    @Override
    public void render(float delta) {
    }
    @Override
    public void resize(int width, int height) {
    }
    @Override
    public void show() {
    }
    @Override
    public void hide() {
    }
    @Override
    public void pause() {
    }
    @Override
    public void resume() {
    }
    @Override
    public void dispose() {
    }
}

教程屏幕类:

package com.art.zok.linkgems.screen;
import com.art.zok.linkgems.Linkgems;
public class TutorialScreen extends AbstractScreen {
    public TutorialScreen(Linkgems linkgems) {
        super(linkgems);
    }
    @Override
    public void render(float delta) {
    }
    @Override
    public void resize(int width, int height) {
    }
    @Override
    public void show() {
    }
    @Override
    public void hide() {
    }
    @Override
    public void pause() {
    }
    @Override
    public void resume() {
    }
    @Override
    public void dispose() {
    }
}

从上面代码可以看出,每个屏幕类都封装了主类的引用,因此我们可以在任何屏幕随时调用主类的方法。为了实现通用的切换过程,我应该将切换方法放在主类进行。

4. 改造Linkgems

  1. 首先,为该类添加一个Map类型的成员变量,该变量将用于存放各个屏幕对象与相应的键值,方便后续管理:
    public class Linkgems extends Game {
    ...
    private Map<String, AbstractScreen> _screens; // screen list
    ....
    
  2. 然后在create方法中创建并初始化该列表:

     @Override
    public void create() {        
        ...
        // create list of screen
        _screens = new HashMap<String, AbstractScreen>();
        _screens.put("menuScreen", new MenuScreen(this));
        _screens.put("tutorialScreen", new TutorialScreen(this));
        _screens.put("gameScreen", new GameScreen(this));
    
        // set default screen
        changeScreen("menuScreen");
    }
    
  3. 上述代码调用了一个名为changeScreen()的方法,接下来我们需要创建该方法:
    // general screen change method
    public boolean changeScreen(String key) {
        AbstractScreen nextScreen = _screens.get(key);
        Screen curScreen  = getScreen();
        if(nextScreen == null || nextScreen == curScreen) 
            return false;
        // pause current screen
        if(curScreen != null)
            curScreen.pause();
        // disable input
        Gdx.input.setInputProcessor(null);
        // change screen
        setScreen(nextScreen);
        // enable input
        Gdx.input.setInputProcessor(nextScreen);
        // reset some state of current screen
        nextScreen.resume();
        return true;
    }
  1. 现在我们的屏幕已经支持了多屏管理,但是我们还需要修改一个非常重要的方法:

      @Override
    public void render() {    
        Gdx.gl.glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
        Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
    
        _batch.setProjectionMatrix(_camera.combined);
        _batch.begin();
        // render current screen
        float delta = Math.min(Gdx.graphics.getDeltaTime(), 1.0f / 60.0f);
        getScreen().render(delta);
        _batch.end();
    }
    
  2. 为了避免使用过多的魔法数和字符串常量,接下我创建一个常量类存储各种常量参数:
package com.art.zok.linkgems.util;
public class Constants {
    // Orthographic camera's viewport size
    public static final int    VIEWPORT_WIDTH  = 1280;
    public static final int    VIEWPORT_HEIGHT = 720;
    // All screens key
    public static final String MENU_SCREEN     = "menuScreen";
    public static final String TUTORIAL_SCREEN = "tutorialScreen";
    public static final String GAME_SCREEN     = "gameScreen";
}

此时,我们就可以将上述代码使用过的魔法数和字面量替换成常量了,这里不再列举了代码了。

5. 通用的切屏动画

上面虽然编写了这么多代码,但是运行游戏还是漆黑一片。接下来本节将为游戏添加一个通用的切屏动画,最终效果如下图所示:

screen change

从上面截图我们可以看到,切换动画包括一行文字(Loading..20%),其中20%表示切屏进程。实现该动画我需要加载一个位图字体,由于切换屏幕在整个应用运行期随时可能发生,因此,该位图字体的生命周期应该与应用相同。

  1. 首先将下面字体文件拷贝到Linkgems-android项目的assets/fonts/目录下:
    • loadingFont.fnt
    • loadingFont.png
  2. 接下来为Constants类添加一个字符串常量:
    // loading text
    public static final String LOADING = "Loading...";
    
  3. 下面修改主类Linkgems:

    public class Linkgems extends Game {
    ...
    private AssetManager _assetManager;
    // loading font
    private BitmapFont _fontLoading;
    @Override
    public void create() {
       ...
       // create assets manager
       _assetManager = new AssetManager();
    
       // in advance load "Loading" font
       BitmapFontParameter parameter = new BitmapFontParameter();
       parameter.flip = true;
       parameter.minFilter = TextureFilter.Linear;
       parameter.magFilter = TextureFilter.Linear;
       _assetManager.load("fonts/loadingFont.fnt", BitmapFont.class, parameter);
    
       // begin load
       _assetManager.finishLoading();
    
       // get and scale "Loading" font
       _fontLoading = _assetManager.get("fonts/loadingFont.fnt", BitmapFont.class);
       _fontLoading.setScale(1.5f);
    
       // create list of screen
       ...
    }
    
    @Override
    public void dispose() {
       super.dispose();
       _batch.dispose();
       _assetManager.dispose();
    }
    
    public BitmapFont getLoadingFont() {
       return _fontLoading;
    }
    
    public SpriteBatch getSpriteBatch() {
       return _batch;
    }
    
    public AssetManager getAssetManager() {
       return _assetManager;
    }
    }
    

    上述代码添加了资源管理器和位图字体,以及几个功能方法。

  4. 接下来修改AbstractScreen类,实现切屏动画。实际上,我们此处实现的切屏动画是为了给下一个屏幕一定时间加载相应的资源(图片、音频等等)。这也是为什么动画仅仅包含一行loading...??%文字,其中百分比也表示资源的加载进度。对于屏幕类来说,最基本的应该包含两种状态:“Loading”和“Done”,这两种状态分别表示后台正在加载资源,前台应该播放动画资源加载成功,进入该屏幕

    所以,首先我们应该为该类定义一个枚举类型,表示屏幕当前所处的状态:

    private enum State {Loading, Done};
    private State _state;
    

    因为游戏每次切换屏幕时都会调用resume()方法重置当前屏幕的状态,因此我们应该在该方法内初始化_state成员变量:

    @Override
    public void resume() {
       _state = State.Loading; 
    }
    

    切记:每个子屏幕类实现的resume()方法都必须调用父类的resume()方法:

    @Override
    public void resume() {
       super.resume();
    }
    

    接下来继续修改该类:

    package com.art.zok.linkgems.screen;
    import com.art.zok.linkgems.Linkgems;
    import com.art.zok.linkgems.util.Constants;
    import com.badlogic.gdx.InputProcessor;
    import com.badlogic.gdx.Screen;
    import com.badlogic.gdx.assets.AssetManager;
    import com.badlogic.gdx.graphics.g2d.BitmapFont;
    import com.badlogic.gdx.graphics.g2d.BitmapFont.TextBounds;
    import com.badlogic.gdx.graphics.g2d.SpriteBatch;
    
    public abstract class AbstractScreen implements Screen, InputProcessor {
       protected Linkgems _parent;
    
       private enum State {Loading, Done};
       private State _state;
    
       protected SpriteBatch _batch;
       protected BitmapFont _fontLoading;
       protected AssetManager _assetManager;
       private TextBounds _loadingTxtBounds;
    
       public AbstractScreen(Linkgems linkgems) {
           this._parent = linkgems; 
           _batch = _parent.getSpriteBatch();
           _fontLoading = _parent.getLoadingFont();
           _assetManager = _parent.getAssetManager();
           _loadingTxtBounds = _fontLoading.getBounds(Constants.LOADING);
       }
    
       @Override
       public void render(float delta) {
           if(_state == State.Loading) {
               if(_assetManager.update()) {
                   assignAssets();
                   _state = State.Done;
               }
               renderLoadingTxt(_assetManager.getProgress());
           } else {
               update(delta);
               render();
           }
       }
    
       private void renderLoadingTxt(float precent) {
           _fontLoading.draw(_batch, 
                    Constants.LOADING + (int)(precent * 100) + "%",
                   (Constants.VIEWPORT_WIDTH - _loadingTxtBounds.width) / 2, 
                   (Constants.VIEWPORT_HEIGHT - _loadingTxtBounds.height) / 2);
       }
    
       public abstract void show();                // load assets
       public abstract void assignAssets();        // get assets
       public abstract void hide();                // unload assets
       public abstract void update(float delta);   // update game world
       public abstract void render();              // render game scene
     ...
    

    首先,我们在构造函数完成了所有成员的初始化。接着实现了render(float delta)方法,该方法完成了整个动画的渲染过程。上述代码还添加了几个新方法,其中,renderLoadingTxt()方法用于渲染动画文本。update(float delta)方法用于更新游戏,render()方法用于具体的场景渲染,因此我应该在子屏幕类实现render()方法和update(float delta)方法,而不应该覆盖render(float delta)方法。接下来观察render(float delta)方法,该方法包含两个执行路径,当屏幕状态等于Loading时,持续判断资源是否加载完成,如果完成则获取资源并切换状态,否则渲染切换动画(一行文字);当屏幕状态等于Done时,表明资源加载成功,此时开始更新本屏幕类(调用update(float delta)方法),并渲染场景。我们知道每次屏幕切入时,首先会调用show()方法,屏幕切出时调用hide()方法,因此我们应该在子类的show()hide()方法内完成资源的预加载和资源卸载。assignAssets()方法是我们新添加的一个抽象方法,每个子类都应该实现该方法,当资源加载完成时,首先会调用该方法,因此我们应该在该方法内从资源管理器中获得资源并初始化子屏幕类的各个成员变量。

    接下来,删除每个子类的render(float delta)方法,并实现render()方法和update(float delta)方法。由于每个子类现在还都是空的,没有添加任何实质的代码,因此结构都是一样的,所以下面只列出了GameScreen类的代码:

    public class GameScreen extends AbstractScreen {
    
       public GameScreen(Linkgems linkgems) {
           super(linkgems);
       }
    
       /** in advance load assets **/
       @Override
       public void show() {        
       }
    
       /** get and initialize assets **/
       @Override
       public void assignAssets() {
       }
    
       /** unload assets **/
       @Override
       public void hide() {
       }
    
       /** update game world **/
       @Override
       public void update(float delta) {
       }
    
       /** render current scene **/
       @Override
       public void render() {
       }
    
       /** window resize **/
       @Override
       public void resize(int width, int height) {
       }
    
       /** game pause **/
       @Override
       public void pause() {
       }
    
       /** initialize or reset current screen state **/
       @Override
       public void resume() {
           super.resume();
       }
    
       /** nonuse : repalce by hide method**/
       @Override
       public void dispose() {
       }
    }
    

    现在你可以尝试运行一下游戏,虽说切换屏幕一闪而过,但是我们还是可以看到切换效果的,之所有切换过程这么快是因为我们在每个子屏幕还有加载任何资源。

    6. 为子屏幕类添加基本场景


    接下来,我们将为三个子屏幕类实现一个基本的场景,最终效果如下:

    Menu Screen

    menu screen
    Tutorial Screen

    tutorial screen
    Game Screen

    game screen

从上面截图可以看到,每个屏幕包含一个背景图片,所以首先我们需要将下面三张图片资源拷贝到android项目的assets/images目录下:

  • board.png
  • mainMenuBackground.png
  • tutorial.png

    接下来修改MenuScreen类:

    public class MenuScreen extends AbstractScreen {
     private TextureRegion _backgroud;
     ...
     @Override
     public void show() {
         _assetManager.load("images/mainMenuBackground.png", Texture.class);
     }
    
     @Override
     public void assignAssets() {
         Texture texture = _assetManager.get("images/mainMenuBackground.png");
         texture.setFilter(TextureFilter.Linear, TextureFilter.Linear);
         _backgroud = new TextureRegion(texture);
         _backgroud.flip(false, true);
     }
    
     @Override
     public void hide() {
         _assetManager.unload("images/mainMenuBackground.png");
     }
     ...
     @Override
     public void render() {
      // render background
         _batch.draw(_backgroud, 0, 0);
     }
     ...
    

    接下来修改TutorialScreen类:

    public class TutorialScreen extends AbstractScreen {
         private TextureRegion _tutorial;
         ...
         @Override
         public void show() {
             _assetManager.load("images/tutorial.png", Texture.class);
         }
         @Override
         public void assignAssets() {
             Texture texture = _assetManager.get("images/tutorial.png", Texture.class);
             texture.setFilter(TextureFilter.Linear, TextureFilter.Linear);
             _tutorial = new TextureRegion(texture);
             _tutorial.flip(false, true);
         }
         @Override
         public void hide() {
             _assetManager.unload("images/tutorial.png");
         }
         @Override
         public void render() {
             // render background
             _batch.draw(_tutorial, 0, 0);
         }
         ...
    

    最后修改GameScreen类:

    public class GameScreen extends AbstractScreen {
         private TextureRegion _boardBackground;
         /** in advance load assets **/
         @Override
         public void show() {     
             _assetManager.load("images/board.png", Texture.class);
         }
    
         /** get and initialize assets **/
         @Override
         public void assignAssets() {
             Texture texture = _assetManager.get("images/board.png", Texture.class);
             texture.setFilter(TextureFilter.Linear, TextureFilter.Linear);
             _boardBackground = new TextureRegion(texture);
             _boardBackground.flip(false, true);
         }
    
         /** unload assets **/
         @Override
         public void hide() {
             _assetManager.unload("images/board.png");
         }
    
         /** render current scene **/
         @Override
         public void render() {
          // render board background
             _batch.draw(_boardBackground, 0, 0);
         }
    

    texture.setFilter(TextureFilter.Linear, TextureFilter.Linear)用于设置纹理的过滤模式,textureRegion.flip(false, true)用于设置纹理域本地坐标系在竖直方向上翻转。从上面代码我们可以看到,每次获得纹理时都要进行同样的设置,为了避免过多的冗余代码,这里我们可以创建一个工具类,该类用于放置一些静态的工具方法:

    package com.art.zok.linkgems.util;
    import com.badlogic.gdx.graphics.Texture;
    import com.badlogic.gdx.graphics.Texture.TextureFilter;
    import com.badlogic.gdx.graphics.g2d.TextureRegion;
    
    public class Tools {
       public static TextureRegion textureToRegion(Texture texture, boolean flip) {
            texture.setFilter(TextureFilter.Linear, TextureFilter.Linear);
            TextureRegion  region = new TextureRegion(texture);
            region.flip(false, flip);
            return region;
       }
    }
    

    现在就可以去掉assignAssets()方法的冗余代码:

       @Override
     public void assignAssets() {
         Texture texture = _assetManager.get("images/mainMenuBackground.png");
         _backgroud = Tools.textureToRegion(texture, true);
     }
    

    7. 添加测试动作

    上面我们已经为每个屏幕类实现了切换动画和基本场景。但是,因为主类在初始化时将菜单屏幕设置为了默认屏幕,所以我们无法观察到其他两个屏幕,接下来,我们将实现一个简单的切换动作,帮助我们测试每个屏幕工作是否正常。

    打开AbstractScreen类,修改keyDown()方法:

    @Override
    public boolean keyDown(int keycode) {
     switch (keycode) {
         case Keys.M:
             _parent.changeScreen(Constants.MENU_SCREEN);
             break;
         case Keys.T:
             _parent.changeScreen(Constants.TUTORIAL_SCREEN);
             break;
         case Keys.G:
             _parent.changeScreen(Constants.TUTORIAL_SCREEN);
             break;
         default:
             break;
     }
     return true;
    }
    

    8. 解决场景拉伸问题


一般来讲,解决场景拉伸有两种方法,这两种方法对应着两种不同的情况。第一种方法的核心思想是,保持视口的高度不变,根据窗口的最新纵横比更新视口的宽度,这样视口的纵横比总是与窗口的纵横比相同,但是视口的宽度会经常改变,该方法适合那些通关游戏,即场景横向显示的多与少不影响游戏体验,比如下图展示的情况:

上述这种解决拉伸的方法只需要在resize()方法添加下面代码即可:

    public void resize (int width, int height) {
        camera.viewportWidth = (Constants.VIEWPORT_HEIGHT / (float)height) * (float)width;
        camera.update();
    }

但是对于像本项目的情况来说,上述方法就很难适应了,因为这类游戏通常需要保持严格的纵横比,即使当窗口尺寸发生改变时,视口的纵横比也不能改变。那么该如果和解决拉伸呢?此时需要介绍LibGDX的一个底层方法:

public void glViewport (int x, int y, int width, int height);

该方法的实质作用是:设置应用窗口中显示场景的区域。也就是说,我们可以通过该方法限制视口的内容在窗口内的显示范围。从描述中,我们可以看出,上述方法各个参数的单位肯定是像素了。如果我们没有直接调用上述方法设置窗口的显示区域,LibGDX将默认该显示区域与窗口尺寸相同。因此,当视口与窗口的纵横比不同时,场景就是被拉伸以填满整个窗口。所以,我们可以通过该方法保证窗口的显示区域与视口的纵横比相同。

实现上述方法,首先我们需要在Constants类中保存视口要求的纵横比:

public static final float  ASPECT_RATIO    = 1.777777f;

接着修改Linkgems类:

public class Linkgems extends Game {
    private Rectangle                   _glviewport;
    @Override
    public void create() {
        _glviewport = new Rectangle(0, 0, Gdx.graphics.getWidth(),
                Gdx.graphics.getHeight());
        ...        
    }

    @Override
    public void render() {
        Gdx.gl.glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
        Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
        Gdx.gl.glViewport((int) _glviewport.x, (int) _glviewport.y,
                (int) _glviewport.width, (int) _glviewport.height);
        _batch.setProjectionMatrix(_camera.combined);
        ...
    }

    @Override
    public void resize(int width, int height) {
        // calculate new viewport
        float aspectRatio = (float) width / (float) height;
        float scale = 1f;
        Vector2 crop = new Vector2(0f, 0f);

        if (aspectRatio > ASPECT_RATIO) {
            scale = (float) height / (float) VIEWPORT_HEIGHT;
            crop.x = (width - VIEWPORT_WIDTH * scale) / 2.0f;
        } else if (aspectRatio < ASPECT_RATIO) {
            scale = (float) width / (float) VIEWPORT_WIDTH;
            crop.y = (height - VIEWPORT_HEIGHT * scale) / 2.0f;
        } else {
            scale = (float) width / (float) VIEWPORT_WIDTH;
        }
        float w = (float) VIEWPORT_WIDTH * scale;
        float h = (float) VIEWPORT_HEIGHT * scale;
        _glviewport.set(crop.x, crop.y, w, h);
        super.resize(width, height);
    }

首先,上述代码为Linkgems类维护了一个定义显示区域的成员变量_glviewport。接着我们在render()方法中,每次渲染场景前首先调用Gdx.gl.glViewport()方法设置显示区域的大小。我们知道,每当应用窗口的尺寸发生改变时,LibGDX总是会主动调用resize()方法,所以我们应该在该方法内完成显示区域的更新。更新显示区域的原理非常简单,因为我们的目标是保证显示区域的纵横比等于视口的纵横比,因此上述代码首先计算了窗口的纵横比,当该值大于要求的纵横比时,则表明窗口的宽度过大,此时应以窗口高度为基准计算缩放比例,然后求解出真正的显示宽度;当纵横比过小时,情况刚好相反。

接下来可以运行游戏并尝试改变窗口尺寸,观察显示区域是如何改变的,下面截图展示了上述这两种情况:

可以看到,每次改变窗口尺寸,显示区域的纵横比是不变的,也就是说,场景不会产生拉伸。如果你没有按照上述方法解决拉伸问题,而是通过为桌面平台的应用窗口设置了一个纵横比相同但固定不变的尺寸来解决的,这将会产生很大问题,因为LibGDX应用在移动设备上默认是全屏显示的,所以,对于纵横比不符合要求的设备游戏画面将会产生严重的拉伸问题。

9. 捕获鼠标


有时候我们希望将系统默认的鼠标指针替换成自定义图标,比如本项目。完成这项功能的基本思路是,1). 获取鼠标指针的位置;2). 渲染自定义图标

results matching ""

    No results matching ""