第二章 — 向上和前进

上一页 | 下一页

现在我们已经有一些开发游戏的经验了,让我们来开发一些更复杂的东西!

设计文档

在我们开始设计和编程我们的下一个游戏之前,我们需要考虑一下功能和其他方面。这通常从任何主要项目的**设计文档**(GDD)开始。为了快速推进,这里有一个简单的例子

   
游戏名称 星际探险者 — 一个太空射击游戏。
描述 驾驶你的宇宙飞船穿越小行星带,摧毁小行星并前进。
控制 点击飞船开火;拖动飞船左右移动。
声音 射击声音和飞船与小行星碰撞时的爆炸声音。

现在我们有了一个非常基本的游戏概要。一旦创建了设计文档,你会惊讶于它增长的速度!

项目设置

考虑到这些基本的设计目标,像上一章一样开始一个新项目

  1. 从**模拟器**中,从**文件**菜单中选择**新建项目...**。

  2. 对于项目/应用程序名称,键入 StarExplorer 并确保选中**空白**模板选项。

  3. 这一次,在可以选择屏幕尺寸的部分,选择**平板电脑预设**。注意宽度和高度值分别更改为 7681024

  4. 将其他设置保留为默认值,然后单击**确定** / **下一步**。

  5. 找到并打开项目文件夹。

设置和配置

除了 main.lua 之外,还有两个重要的文件可以配置你的应用程序在多种类型的设备上正常工作:build.settingsconfig.lua

构建设置

build.settings 文件为真实设备(手机、平板电脑等)提供它们需要的关于你的应用程序的详细信息。这包括应用程序支持的方向、图标文件的名称、要包含的插件、设备所需的特殊信息等等。

使用你的文本编辑器,在你的项目文件夹中找到并打开 build.settings 文件。它将包含很多信息,但让我们关注突出显示的代码块

settings =
{
    orientation =
    {
        -- Supported values for orientation:
        -- portrait, portraitUpsideDown, landscapeLeft, landscapeRight

        default = "portrait",
        supported = { "portrait", },
    },

我们的 StarExplorer 应用程序只能在**纵向**模式下运行,因此我们在以下两行设置它

  • default = "portrait",— 此行指定当用户首次加载应用程序时,游戏应以纵向方向**开始**。

  • supported = { "portrait", },— 此行指定唯一**支持**的方向也是纵向。这意味着即使用户将物理设备旋转(方向)到他们的手中,你的应用程序仍将保持锁定在纵向方向。

正如你在上一章中学到的,花括号表示 Lua 编程语言中的**表**,并且表可以包含多个项目。我们可以在此表中包含最多四个支持的方向,但由于此应用程序仅支持一个方向,因此我们只需要包含 "portrait"

build.settings 文件中还可以包含其他一些内容,但为了使本指南尽可能简单,让我们继续。

配置选项

config.lua 文件包含应用程序配置设置。这是我们指定应用程序将以什么内容分辨率运行、内容缩放模式、如何高分辨率设备应该如何处理等等。

使用你的文本编辑器,在你的项目文件夹中找到并打开 config.lua 文件并检查以下突出显示的行

application =
{
    content =
    {
        width = 768,
        height = 1024, 
        scale = "letterbox",

请注意,此 content 表包含一系列配置设置,包括

  • widthheight — 这些值指定应用程序的**内容区域**大小。你的基本内容区域可以是你希望的任何大小,但通常它基于常见的屏幕宽度/高度纵横比,例如这里的 7681024 设置的 3:4。

重要的是要理解,这些值**并不**表示确切的像素数,而是应用程序内容“单位”的相对数量。内容区域将缩放以适应任何设备的屏幕,细微的差异由 scale 定义决定(见下一点).

  • scale — 此重要设置指定如何处理与 widthheight 设置定义的纵横比(例如本例中的 3:4)不匹配的屏幕的内容区域。两个最常见的选项是 "letterbox""zoomEven"

"letterbox" 将内容区域缩放以填充屏幕,同时保持相同的纵横比。整个内容区域将位于屏幕上,但这可能会导致纵横比与你的内容纵横比不同的设备上出现“黑条”。但是请注意,你仍然可以利用此“空白”区域并通过将它们定位或扩展到内容区域边界之外来用可视元素填充它。本质上,如果要确保内容区域中的所有内容都显示在所有设备的屏幕边界内,则 "letterbox" 是理想的缩放模式。

"zoomEven" 将内容区域缩放以填充屏幕,同时保持相同的纵横比。某些内容可能会在纵横比与你的内容纵横比不同的设备上“溢出”屏幕边缘。基本上,"zoomEven" 是一个很好的选择,可以确保所有设备上的整个屏幕都被内容区域填充(并且外边缘附近的内容裁剪是可以接受的)。

letterbox(黑边) zoomEven(均匀缩放)

对于这个游戏,让我们使用 "zoomEven" 缩放模式。将scale = "letterbox",更改为以下内容,并记住之后保存你的 config.lua 文件!

application =
{
    content =
    {
        width = 768,
        height = 1024, 
        scale = "zoomEven",

游戏基础

现在你已经了解了基本的设置和配置,让我们继续创建游戏。

物理设置

BalloonTap 项目一样,该游戏将使用物理引擎,因此让我们从一开始就对其进行配置。如果你正在使用物理引擎,通常最好在程序的早期包含你的基本物理设置代码。

使用你选择的文本编辑器,打开项目文件夹中的 main.lua 文件并键入以下命令

local physics = require( "physics" )
physics.start()
physics.setGravity( 0, 0 )

如你所见,在加载并启动物理引擎后,我们设置了**重力**值。默认情况下,物理引擎模拟标准地球重力,这会导致物体向屏幕底部坠落。要更改此设置,我们使用 physics.setGravity() 命令,该命令可以在水平 (**x**) 或垂直 (**y**) 方向上模拟重力。由于这个游戏发生在外太空,我们将假设重力不适用。因此,我们将两个值都设置为 0

随机种子

我们的游戏将在屏幕边缘外随机生成小行星,因此我们将在本项目中进一步实现随机数生成。但是,首先,我们需要为伪随机数生成器设置“种子”。在某些操作系统上,此生成器以相同的初始值开始,这会导致随机数以可预测的模式重复。对我们的代码进行以下添加可确保生成器始终以不同的种子开始。

local physics = require( "physics" )
physics.start()
physics.setGravity( 0, 0 )

-- Seed the random number generator
math.randomseed( os.time() )

在这两行的第一行,观察双减号 (--)。这些用于告诉 Lua 它应该忽略该行上的所有其他内容。这样你就可以在代码中留下**注释**(笔记)。当你最初编写程序时,这似乎并不重要,但应用程序越复杂,或者你再次返回处理应用程序的时间越长,包含注释以供参考就越重要。

当你打算在应用程序中生成随机数时,只需设置一次伪随机数字生成器种子,通常在 main.lua 中。多次这样做是多余且不必要的。

包含图像

最初,我们只需要此项目的两个视觉资产。和以前一样,它们应该放在你的 StarExplorer 项目文件夹中。

文件 大小 (宽×高) 用途
background.png 800 × 1400 背景图像(星星)。
gameObjects.png 112 × 334 包含我们游戏对象的图像表。

你可以使用我们的图像作为起点,可与本章的源文件一起使用。

加载图像表

如果你使用过其他游戏开发平台,你可能熟悉术语“精灵表”或“纹理图集”。在 Solar2D 中,它被称为**图像表**,它允许你从单个更大的图像文件中加载多个图像/帧。图像表也用于动画精灵,其中精灵的帧从表中提取并组合成动画序列。

注意

图像表最容易使用诸如 TexturePacker 之类的工具创建,该工具收集、组织并将多个图像打包成兼容的图像表。虽然此软件不是必需的,但它可以节省你的时间和精力。

按照你现有的代码,创建一个如下所示的 sheetOptions 表。请注意,它应该包含一个子表 frames,其中将包含一个定义表中每个帧的 Lua 表数组。

-- Seed the random number generator
math.randomseed( os.time() )

-- Configure image sheet
local sheetOptions =
{
    frames =
    {

    },
}

配置图像表时,你必须指定图像在表中的位置。对于此游戏,每个图像的大小都不同,因此我们必须提供四个特定属性来定义要使用的图像表部分的理论“边界框”

  1. x — 图像**x** 坐标中相对于整个表宽度的左上角点。上左
  2. y — 图像**y** 坐标中相对于整个表高度的左上角点。上左
  3. width — 图像的总宽度。
  4. height — 图像的总高度。

考虑到这个概念,在 frames 表中添加五个 Lua 表来声明表中每个图像的位置和大小

-- Configure image sheet
local sheetOptions =
{
    frames =
    {
        {   -- 1) asteroid 1
            x = 0,
            y = 0,
            width = 102,
            height = 85
        },
        {   -- 2) asteroid 2
            x = 0,
            y = 85,
            width = 90,
            height = 83
        },
        {   -- 3) asteroid 3
            x = 0,
            y = 168,
            width = 100,
            height = 97
        },
        {   -- 4) ship
            x = 0,
            y = 265,
            width = 98,
            height = 79
        },
        {   -- 5) laser
            x = 98,
            y = 265,
            width = 14,
            height = 40
        },
    },
}

你在表中声明每个图像的**顺序**非常重要 — 稍后,当你使用 display.newImageRect() 等命令从表中加载图像时,你需要根据声明顺序指定帧的**编号**在工作表配置中。

有了这个信息表,我们现在可以使用 graphics.newImageSheet() 命令将图像表加载到内存中。它接受图像表图像文件的名称 (gameObjects.png) 和我们刚刚创建的 sheetOptions 表的引用

    },
}
local objectSheet = graphics.newImageSheet( "gameObjects.png", sheetOptions )

初始化变量

在较大的程序中,最好在文件开头附近声明文件中使用的所有变量。这有助于我们保持井井有条,并且如果我们忘记变量的名称和/或变量具有初始值,则可以提供快速参考。

在图像表选项和设置之后,添加以下行

-- Initialize variables
local lives = 3
local score = 0
local died = false

local asteroidsTable = {}

local ship
local gameLoopTimer
local livesText
local scoreText

让我们详细研究每一组添加

  • 首先我们声明变量来跟踪剩余的生命数(初始值为 3,当前分数(初始值为 0,以及玩家是否已死亡(最初为 false.

  • 接下来,我们声明变量 `asteroidsTable`,它将在整个游戏中用于一个非常特定的目的。正如你在前一章中所记得的,花括号 (`{}`) 表示 Lua (数组)。除其他事项外,表用于跟踪相似类型的信息。由于此游戏中屏幕上将有许多小行星,因此将每个小行星声明为唯一变量(例如 `asteroid1`、`asteroid2`、`asteroid15` 等)是不切实际的。我们需要一种更有效的方法,这就是表可以提供帮助的地方——本质上,表可以包含大量的对象引用和其他数据,并在游戏的整个生命周期中根据需要增长和缩小。

  • 在接下来的几行中,我们声明了飞船对象 (`ship`) 的变量占位符、稍后将实现的计时器的占位符,以及两个用于显示玩家剩余生命和得分的文本对象的变量。

请注意,最后四个声明没有以任何初始值或类型开头。在 Lua 中,这是进行**前向声明**的一种完全有效的方法,有时也称为**上值**。实际上,我们是在告诉 Lua 变量的存在,即使我们不打算在程序的后面使用它。

太好了!设置了初始变量后,我们就可以开始将对象加载到屏幕上了。

使用显示组

在上一章中,我们只是将对象放置在**舞台**上——这本质上是所有显示对象存在其中的核心层/组。在这个游戏中,我们将对象插入到不同的**显示组**中,以便进行更可控的分层和组织。基本上,显示组是一种特殊的显示对象,它可以包含其他显示对象,甚至其他显示组。可以将其想象成一张空白的纸,您可以在上面“绘制”图像、文本、形状和动画精灵。

考虑到这一点,让我们创建三个初始显示组。在你已经输入的行之后,添加这些命令

-- Set up display groups
local backGroup = display.newGroup()  -- Display group for the background image
local mainGroup = display.newGroup()  -- Display group for the ship, asteroids, lasers, etc.
local uiGroup = display.newGroup()    -- Display group for UI objects like the score
注意

这里最重要的方面是创建组的**顺序**。在上一章中,你了解到连续加载的图像在视觉分层方面将从后到前堆叠。同样的原则也适用于显示组——不仅插入到显示组中的显示对象将按照这种从后到前的方式堆叠/分层,而且你还可以通过以类似的顺序创建显示组来将它们一层一层地叠加。

加载背景

与上一个项目一样,我们将首先加载背景图像

-- Load the background
local background = display.newImageRect( backGroup, "background.png", 800, 1400 )
background.x = display.contentCenterX
background.y = display.contentCenterY

此时,这些命令应该看起来很简单,但有一个非常重要的区别!检查 display.newImageRect() 的第一个参数——在图像文件名(现在是第二个参数)之前,我们指明了放置对象的**显示组** (`backGroup`)。这是一个方便的内联快捷方式,用于指定在加载背景图像后要插入的显示组。

加载飞船

如前所述,图像表可以用作动画图像序列(精灵)的来源,**或者**可以用来组织将在你的应用程序中使用的静态图像集合。对于这个项目,我们将使用第二种方法。

从图像表加载单个图像类似于从文件加载它。但是,我们不是提供图像名称,而是指定对图像表的引用以及帧编号。将以下突出显示的行添加到你的项目代码中

local background = display.newImageRect( backGroup, "background.png", 800, 1400 )
background.x = display.contentCenterX
background.y = display.contentCenterY

ship = display.newImageRect( mainGroup, objectSheet, 4, 98, 79 )

让我们更详细地检查一下这个命令

  • 第一个参数指定将放置对象的显示组 (`mainGroup`)。(而不是简单地在舞台上)。

  • 第二个参数是对我们之前加载的图像表 (`objectSheet`) 的引用。

  • 由于飞船是图像表配置中的第 4 帧,因此我们将 `4` 指定为帧编号(第三个参数)。

  • 最后,像往常一样,我们将宽度和高度设置为 `98` 和 `79`,与图像表配置中帧的 `width` 和 `height` 值匹配。

请记住,我们在程序的前面为飞船对象声明了一个**前向引用**

local ship
local gameLoopTimer
local livesText

因此,我们不需要在新的`ship = display.newImageRect()`命令前面加上 `local`。本质上,我们现在只是将之前声明的 `ship` 变量设置为实际值(一个图像对象).

让我们继续使用以下突出显示的命令配置飞船

ship = display.newImageRect( mainGroup, objectSheet, 4, 98, 79 )
ship.x = display.contentCenterX
ship.y = display.contentHeight - 100
physics.addBody( ship, { radius=30, isSensor=true } )
ship.myName = "ship"

使用前两个命令,我们将飞船定位在屏幕的底部中心。你将从上一章中认识到 `display.contentCenterX`,但这次我们使用了一个新命令 `display.contentHeight`。此便捷属性指示内容区域的最大 **y** 坐标(屏幕的底部边缘)。但是,由于飞船应位于此点上方,因此我们从此值中减去 `100`。

接下来,我们将飞船添加到物理引擎中,并将 `radius` 属性设置为 `30`。此外,我们还指定了一个重要的属性:`isSensor=true`。这指定该对象应为**传感器**对象。本质上,传感器对象检测与其他物理对象的碰撞,但它们**不会**产生物理响应。例如,如果一个普通物体与一个传感器物体碰撞,这两个物体不会相互反弹或引起任何其他物理反应。这对我们的飞船对象来说非常理想,因为我们只希望它检测与小行星的碰撞,而不是从它们身上反弹。

最后,我们给飞船对象一个 `myName` 属性,值为 `"ship"`。此属性稍后将用于帮助确定游戏中正在发生什么类型的碰撞。

注意

虽然飞船图像显然不是圆形的,但我们向飞船对象添加了一个圆形物理体(`radius=30`)。对于这个游戏,我们只是通过使用这种近似的体形来“偷懒”——一旦你对 Solar2D 更加熟悉,你将学习如何向飞船添加一个完美的基于形状的物理体,以精确匹配飞船图像的轮廓。

生命和得分

现在,让我们在屏幕上放置玩家生命值和得分的 UI 标签

-- Display lives and score
livesText = display.newText( uiGroup, "Lives: " .. lives, 200, 80, native.systemFont, 36 )
scoreText = display.newText( uiGroup, "Score: " .. score, 400, 80, native.systemFont, 36 )

为了简单起见,我们使用标签的特殊 Lua 方法来显示我们的生命值和得分。在 Lua 中将两个句点放在一起(`..`)称为**连接**。连接将两个字符串连接成一个。因此,在`livesText = display.newText()`上面的命令中,我们将字符串 `Lives:` 和变量 `lives` 连接起来,结果为`Lives: 3`显示在屏幕上(记住我们之前将 `lives` 的初始值设置为 `3`)。类似地,对于 `scoreText`,我们将字符串 `Score:` 和变量 `score` 连接起来,结果为`Score: 0`.

让我们检查一下到目前为止代码的结果。保存修改后的 `main.lua` 文件并重新启动模拟器。如果一切顺利,背景、飞船和文本标签现在应该显示在屏幕上。

到目前为止,一切看起来都很好,除了屏幕顶部略微分散注意力的**状态栏**。在移动设备上,此系统生成的元素通常显示各种信息,如蜂窝/WiFi 连接强度、时间、剩余电池电量等。虽然此信息对于设备的一般使用很有价值,但在用户玩游戏时可能会分散注意力。要隐藏状态栏,请添加以下内容

-- Hide the status bar
display.setStatusBar( display.HiddenStatusBar )

最后,让我们编写一个函数来更新 `livesText` 和 `scoreText` 的 `text` 属性。这将类似于我们在上一章中编写的函数。正如你所记得的,每当你使用 display.newText() 创建一个新标签或对象时,文本的标签/值都会存储在对象的 `text` 属性中,因此我们使用标签及其关联变量的**连接**字符串值来更新该属性。

local function updateText()
    livesText.text = "Lives: " .. lives
    scoreText.text = "Score: " .. score
end

现在我们已经有了基本的视觉对象,可以从图像表加载图像,并更新生命值和得分的 UI 标签,我们就可以开始游戏逻辑和事件了!

作为参考,到目前为止的程序可在此处此处获得。如果你的项目没有按预期运行,请将此源代码与你自己的代码进行比较。

章节概念

我们在本章中介绍了几个概念。以下是快速概述

命令/属性 描述
physics.setGravity() 设置全局重力矢量的 **x** 和 **y** 分量,单位为 m/s²。
math.randomseed() 设置伪随机随机数生成器的“种子”。
graphics.newImageSheet() 创建一个 ImageSheet 对象,用于从单个更大的图像文件加载多个图像/帧。
display.newGroup() 创建一个显示组,用于组织/分层显示对象。
display.contentHeight 内容区域高度的快捷方式。
display.setStatusBar() 隐藏或更改大多数设备上状态栏的外观。