65.9K
CodeProject 正在变化。 阅读更多。
Home

初学者移动游戏编程:第二部分,共四部分

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.94/5 (28投票s)

2009年5月17日

CPOL

12分钟阅读

viewsIcon

77204

downloadIcon

2232

J2ME游戏编程初学者指南的第二部分,共四部分。

BrickBreaker

引言

本文是我“移动游戏编程入门”系列中的第二部分。本系列从一个超级简单的游戏开始,但将继续展示如何实现各种不同类型的游戏以及用于编码这些游戏的技巧。

这是一个四部分系列,我将介绍以下游戏:

  • 基础
  • 打砖块风格
  • 俯视卷轴游戏
  • 3D太空游戏

本部分将向您展示如何实现一个打砖块风格的游戏。

在此游戏中,目标是让球从挡板上弹开,并让球与“砖块”碰撞;如果玩家未能用挡板挡住球,则游戏结束。当球与砖块碰撞时,砖块将被移除。当所有砖块都被移除时,玩家就赢得了游戏。在本部分中,我将讨论以下主题:

  • 菜单和游戏内菜单
  • AI或计算机控制的玩家
  • 碰撞检测

这款游戏,尽管仍然是一款非常简单的游戏,但编写起来要复杂得多,这就是为什么这个例子包含20个类而不是像本系列上一部分那样包含2个类。然而,大多数类都是通用的,可以重复用于其他项目,所以它并不像看起来那么复杂。我不会在本文中展示所有类的代码摘录,但我已对可下载的类进行了注释,因此对于本文未讨论的类或概念,请参考代码注释。

类概述

这个例子中最重要的类是:

  • MainCanvas:初始化游戏的屏幕,并控制屏幕之间的更新、渲染和转换。
  • BrickBreakerScreen:包含游戏逻辑的类。
  • Ball:控制球的运动、碰撞检测和渲染。
  • Brick:单个砖块的表示。
  • BrickField:游戏中所有砖块的表示。
  • Paddle:挡板的表示;请注意,此类依赖于PaddleController来实际控制挡板。
  • PaddleController:由ComputerPaddleControllerHumanPaddleController实现的接口,允许人类或计算机AI控制挡板。
  • MenuScreen:一个简单、通用的游戏菜单的实现。

菜单

我喜欢编写游戏,但有一件事我不喜欢,那就是编写所有不属于游戏玩法的小零碎,例如菜单和游戏内对话框。然而,花时间实现这些部分也很重要;否则,游戏将不会显得完善,用户也将无法配置游戏设置。

因此,在本部分中,我将花一些时间向您展示如何实现游戏菜单以及如何处理从渲染菜单到渲染游戏的转换。由于本系列本部分实现的游戏是一款简单的打砖块风格游戏,菜单选项不多,但拥有菜单以使游戏感觉更完整仍然很重要。

屏幕转换

为了从渲染菜单过渡到渲染实际游戏,游戏必须能够处理不同类型的屏幕。MIDlet可用于在不同的Canvas之间切换,但我更喜欢始终只使用一个GameCanvas来维护其GameScreen集。

GameScreen

GameScreen类是一个抽象类,它公开了我的主MIDlet类所需的几个方法,以便控制和更新GameScreen的状态并请求其自行渲染。

package com.bornander.games.utils;

import javax.microedition.lcdui.Graphics;

public abstract class GameScreen {

    protected Background background;
    protected int width;
    protected int height;

    public GameScreen(int width, int height) {
        this.width = width;
        this.height = height;
        this.background = null;
    }

    public void setBackground(Background background) {
        this.background = background;
    }

    protected void paintBackground(Graphics graphics) {
        if (background != null)
            background.paint(graphics);
    }

    public abstract void activated(GameScreen previousScreen);

    public abstract void paint(Graphics graphics);

    public abstract int doUpdate();

    public abstract void keyPressed(int keyCode, int gameAction);

    public abstract void keyReleased(int keyCode, int gameAction);
}

通过使用像GameScreen类这样的构造,可以很容易地编写一个相当简单的Canvas实现(我称之为我的主画布),它可以初始化游戏所需的屏幕,然后根据活动屏幕的本地逻辑处理它们之间的转换。游戏以菜单屏幕作为活动屏幕启动,并且当选择了相关的菜单选项时,该屏幕可以请求主画布将屏幕更改为实际游戏屏幕。这样,菜单GameScreen的实际实现可以完全通用,并可用于其他游戏。我真的很喜欢这一点,因为正如我所说,我不太喜欢写菜单。在这个例子中,有一个名为MenuScreen的实用类,它是菜单的通用实现,我将在本系列的另外两部分中重复使用它。

MenuScreen

MenuScreen类是GameScreen类的一个相当简单的实现。它允许程序员定义一组菜单选项,然后根据按键渲染这些选项。它同时处理简单的项目和“多项选择”项目(例如声音开关)。MenuScreen本身不了解实际的游戏,所以它不能直接配置游戏,但是当屏幕转换发生时,游戏可以从前一个屏幕检索设置,因为该设置作为参数传递。

游戏内对话框

有时需要中断游戏,并在游戏过程中向玩家显示信息和/或选项。在这些情况下,显示一个游戏内对话框比将用户带回一个功能齐全的菜单屏幕要整洁得多。这意味着实际的游戏屏幕负责处理从游戏进行到显示对话框的转换,而主画布与这种类型的转换无关。

一种这样的情况是用户暂停游戏,在此示例中,通过单击Fire按钮来完成。当游戏进入暂停状态时,它会停止更新挡板和球的位置。它仍然渲染它们,但也会在前台渲染一个对话框。游戏状态从运行变为暂停,输入处理方式也随之改变;在运行模式下,按UpDown会调整音量,但在暂停状态下,它会恢复或返回主菜单。

BrickBreakerScreen类使用的不同状态是:

  • Starting:游戏启动时,这给了玩家一点准备时间。
  • Running:游戏进行中。
  • Stopped:游戏结束后进入此状态。
  • Paused:用户已暂停游戏。

这些状态在BrickBreakerScreen中定义为简单的int

public class BrickBreakerScreen extends GameScreen {

    ...

    // The different game states
    private final static int GAME_STATE_STARTING = 0;
    private final static int GAME_STATE_RUNNING = 1;
    private final static int GAME_STATE_STOPPED = 2;
    private final static int GAME_STATE_PAUSED = 3;

    ...
}

AI

大多数游戏的一个重要部分是智能行为的电脑对手,虽然打砖块游戏可能不是讨论AI时首先想到的游戏,但我认为这是一个不错的起点,因为实际的AI很容易实现(挡板只能左右移动)。它还可以让我展示一个良好的抽象类设计不仅易于在游戏中创建计算机控制的实体,还可以使其更易于调试并添加网络支持。

抽象控制器

游戏中的AI就是电脑试图控制挡板,正如我所说,这是一项相当容易的任务,因为挡板只有几种可以做的事情:

  • 向左移动
  • 向右移动
  • 不移动

AI用来决定选择哪种选项的信息是:

  • 球的位置
  • 挡板的位置和大小

我们可以争辩说球的速度也应该是一个输入,但为了简单起见,我现在将忽略它。这些信息与人类使用的信息相同。区别在于人类需要通过按键输入选择。因此,如果创建一个接口,该接口允许使用上述信息,并产生输出(左、右、不移动),同时考虑按键,那么该接口将适用于人类控制器和计算机控制器。我决定将此接口命名为PaddleController

public interface PaddleController {

    public static final int COMMAND_NOTHING = 0;
    public static final int COMMAND_MOVE_LEFT = 1;
    public static final int COMMAND_MOVE_RIGHT = 2;

    void initialize();

    void updatePaddleData(int x, int y, int width);

    void updateBall(int x, int y, int deltaX, int deltaY);

    void keyPressed(int keyCode, int gameAction);

    void keyReleased(int keyCode, int gameAction);

    int getCommand();
}

通过updatePaddleDataupdateBall方法提供有关球和挡板的信息给控制器;通过keyPressedkeyReleased捕获有关按键的信息,而getCommand方法返回动作。

用于人类玩家的控制器版本如下所示:

public class HumanPaddleController implements PaddleController {

    private boolean leftPressed = false;
    private boolean rightPressed = false;

    public HumanPaddleController() {
    }

    public void initialize() {
    }

    public void updatePaddleData(int x, int y, int width) {
    }

    public void updateBall(int x, int y, int deltaX, int deltaY) {
    }

    public void keyPressed(int keyCode, int gameAction) {
        switch(gameAction) {
            case Canvas.LEFT: leftPressed = true; break;
            case Canvas.RIGHT: rightPressed = true; break;
        }
    }

    public void keyReleased(int keyCode, int gameAction) {
        switch(gameAction) {
            case Canvas.LEFT: leftPressed = false; break;
            case Canvas.RIGHT: rightPressed = false; break;
        }
    }

    public int getCommand() {
        if (leftPressed)
            return PaddleController.COMMAND_MOVE_LEFT;
        if (rightPressed)
            return PaddleController.COMMAND_MOVE_RIGHT;

        return PaddleController.COMMAND_NOTHING;
    }
}

计算机控制版本实现如下:

public class ComputerPaddleController implements PaddleController {

    private int width;
    private int height;

    private int ballX = 0;
    private int ballY = 0;
    private int ballDeltaX = 0;
    private int ballDeltaY = 0;

    private int paddleX = 0;
    private int paddleY = 0;
    private int paddleWidth = 0;

    public ComputerPaddleController(int width, int height) {
        this.width = width;
        this.height = height;
    }

    public void updatePaddleData(int x, int y, int width) {
        paddleX = x;
        paddleY = y;
        paddleWidth = width;
    }

    public void updateBall(int x, int y, int deltaX, int deltaY) {
        ballX = x;
        ballY = y;
        ballDeltaX = deltaX;
        ballDeltaY = deltaY;
    }

    public void keyReleased(int keyCode, int gameAction) {
    }

    public void keyPressed(int keyCode, int gameAction) {
    }

    public void initialize() {
    }

    public int getCommand() {
        if (ballDeltaY < 0) {
            // If ball is moving up, place paddle in center
            int targetDifferance = paddleX + (paddleWidth / 2) - width / 2;
            if (Math.abs(targetDifferance) > paddleWidth / 10) {
                if (targetDifferance < 0)
                    return PaddleController.COMMAND_MOVE_RIGHT;
                if (targetDifferance > 0)
                    return PaddleController.COMMAND_MOVE_LEFT;
            }
        }
        else {
            // If ball is coming down, move towards it
            int targetDifference = paddleX + (paddleWidth / 2) - ballX;
            if (Math.abs(targetDifference) > paddleWidth / 12) {
                if (targetDifference < 0)
                    return PaddleController.COMMAND_MOVE_RIGHT;
                if (targetDifference > 0)
                    return PaddleController.COMMAND_MOVE_LEFT;
            }
        }
        return PaddleController.COMMAND_NOTHING;
    }
}

我们可以争辩说,按键输入方法实际上不应该属于这个接口,并且HumanPaddleController应该以其他方式读取输入。虽然这样接口会更干净,并且对挡板的控制器有更好的抽象,但我出于简单起见选择了这种方法。

以这种方式抽象控制器的一个优点是它便于调试;电脑将始终以完全相同的方式进行游戏(前提是起始条件相同),这意味着程序员不必手动玩游戏来测试例如游戏通关后的游戏结束状态。还可以编写一个根据人类控制器录制的指令集运行的电脑控制器。这对于简单游戏来说是一个很好的功能,因为它是回到发现bug的状态的便捷方法。这种抽象还暗示了创建网络控制器的方法,以便可以创建多人游戏。网络控制器将简单地连接到一个接收输入并提供命令的客户端控制器,并且实现将对一个玩家是远程玩家(尽管此示例对于打砖块风格的游戏不太适用)透明。

碰撞检测

碰撞检测是许多不同游戏中一个重要的部分,无论是为了找出球何时击中挡板,还是角色何时撞墙。根据游戏的类型,碰撞检测可以以不同的方式实现。一种方法是检查两个物体是否发生碰撞以及它们是否已分开。这就是我在本例中采用的方法,但我对其进行了结构化,以便从Ball的角度来看,在球实际进入其中一个砖块或挡板之前,就已经改变了运动。

边界框

我碰撞检测的核心是BoundingBox类,它表示一个可以测试碰撞的矩形。边界形状的概念是许多游戏碰撞检测的核心,并且有许多变体,如边界球体、圆柱体和圆锥体。

BoundingBox包含矩形的位置和大小,利用这些信息,很容易确定一个点是否位于框的外部或内部(碰撞)。

public class BoundingBox {

    ...

    private int x;
    private int y;
    private int width;
    private int height;

    public BoundingBox(int x, int y, int width, int height) {
        this.x = x;
        this.y = y;
        this.width = width;
        this.height = height;
    }

    public boolean isInside(int px, int py) {
        if (px < x || px > x + width)
            return false;
        if (py < y || py > y + height)
            return false;

        return true;
    }
    
    ...
}

但是,仅确定是否发生碰撞是不够的。根据我的经验(当然,在这方面经验非常有限),弄清楚是否发生碰撞通常很容易。难以计算的是碰撞响应以及为此所需的的信息。在这个示例游戏中,球需要以正确的方式从墙壁、砖块和挡板上反弹。仅仅反转球的方向是不行的,因为球总是以一个角度进入。因此,计算球与挡板、墙壁或砖块的哪个侧面发生碰撞很重要。

对于简单的打砖块游戏,碰撞响应非常简单;如果球与水平边缘碰撞,则否定垂直速度,反之亦然。问题是如何弄清楚球与哪个边缘碰撞。我使用了这个方法:

  1. 检查Ball的当前位置加上其当前速度是否会使其进入BoundingBox
  2. 如果将发生碰撞,请使用当前速度和相对于BoundingBox位置的位置来确定Ball与哪个边缘碰撞。
  3. 使用边缘信息来限制Ball的移动,使其仅移动到BoundingBox的边缘。
  4. 根据碰撞的边缘,否定垂直或水平速度。

我设计了一个区域系统,根据球所在的区域(区域与BoundingBox的边缘相关),球可能只与两个可能的边缘发生碰撞。

示例

在这个例子中,Ball位于区域8,并且以每秒2个单位的水平速度和每秒1个单位的垂直速度移动。从区域8出发,Ball只能与底部和右侧边缘碰撞。但由于水平速度大于垂直速度,碰撞的边缘将是底部边缘。

不难看出这种方法存在缺陷,并且在某些情况下会选择错误的边缘作为碰撞边缘,但对于这个例子来说已经足够了。(要纠正此方法,您需要从最接近的角向Ball的负速度方向延伸一个向量,然后确定Ball在该向量的哪一侧;在此示例中我没有精力实现它,我把所有向量数学都留到第4部分了。 :)

BoundingBox返回一个CollisionInformation对象,其中包含有关碰撞将在哪个边缘发生的碰撞信息;然后Ball使用此信息来调整其位置和速度。

public class Ball {

    ...

    public int processCollisions(BrickField brickField) {
        int numberOfRemovedBricks = 0;

        // For each brick in the brick field...
        for(int row = 0; row < brickField.getNumberOfRows(); ++row) {
            for(int column = 0; column < brickField.getNumberOfColumns(); ++column) {
                Brick brick = brickField.getBrick(row, column);

                // If the brick isn't already removed...
                if (!brick.isRemoved()) {
                    BoundingBox brickBoundingBox = brick.getBoundingBox();

                    for (int i = 0; i < xOffset.length; ++i) {
                        int ox = x + xOffset[i];
                        int oy = y + yOffset[i];

                        // Check if there will be a collision
                        BoundingBox.CollisionInfo collisionInfo = 
                          brickBoundingBox.willCollide(ox, oy, deltaX, deltaY);
                        if (collisionInfo != null) {
                            // If there is a collision, remove the brick...
                            brick.remove();
                            ++numberOfRemovedBricks;

                            // ...correct the position to the edge of the brick...
                            x += collisionInfo.CorrectionOffsetX;
                            y += collisionInfo.CorrectionOffsetY;

                            // ...and alter the ball's velocity
                            // depending on the edge collided with.
                            switch(collisionInfo.Edge) {
                                case BoundingBox.EDGE_BOTTOM:
                                case BoundingBox.EDGE_TOP:
                                    deltaY = -deltaY;
                                    break;
                                case BoundingBox.EDGE_LEFT:
                                case BoundingBox.EDGE_RIGHT:
                                    deltaX = -deltaX;
                            }
                        }
                    }
                }
            }
        }
        return numberOfRemovedBricks;
    }

挡板和墙壁也使用了类似的方法。

关注点

生成的资源

图形

在这个例子中,您会注意到没有包含图像或精灵,因为游戏中的所有图形都是即时生成的。如果游戏具有简单的图形,则很容易做到,方法是使用Graphics对象上的不同draw方法。砖块、球和挡板都是使用屏幕的当前宽度和高度生成的。这意味着即使屏幕的纵横比或大小发生变化,游戏看起来也会不错。

此截图显示了游戏在不同屏幕尺寸的模拟器上运行

音效

游戏中的声音也是生成的;请查看SoundManager类,了解您如何让设备播放简单的音符。该类还知道如何将音量状态渲染到屏幕上,类似于您在电视屏幕上更改音量时看到的那样。

下一部分

在下一部分中,我将在演示如何编写俯视卷轴游戏时讨论J2ME的TiledLayer。我还会介绍如何加载游戏关卡等资源,并继续展示如何使用离屏、菜单屏幕和AI控制器。

一如既往,非常欢迎对文章或代码提出任何评论。

© . All rights reserved.