第 1 章 点击动作游戏——怪物

第 1 章 点击动作游戏 —— 怪物

{%}

在怪物群中穿梭斩杀!

1.1 玩法介绍 How to Play

√ 在怪物群中穿梭斩杀!

  • 武士能够自动行走。

√ 点击按键攻击怪物!

  • 点击鼠标按键后发起攻击,打倒怪物。

  • 在指定时间内尽可能多地击倒怪物。

{%}

√ 一次性斩杀多个怪物!

  • 对于密集分布的多个怪物,可以通过一次攻击就将其全部斩杀。

  • 被砍中的怪物向四面八方飞散。

√ 近处斩杀怪物将得到高分!

  • 斩杀怪物时离得越近,得分越高。

√ 如果不出现失误,怪物的数量将会增加!得分也会增加!

  • 连续斩杀怪物后,出现的怪物数量会逐渐增加。

  • 尽可能零失误地持续斩杀怪物,是获得高分的秘诀。

√ 碰到怪物后将失败!

  • 一旦武士和怪物发生接触,游戏就会结束。

1.2 简单的操作和爽快感 Concept

不论创作何种游戏,都会有一些在刚开始时就必须考虑的事情。那就是游戏的内容

我们在玩游戏的时候,在编写代码的时候,在漫无目的地浏览网页的时候,可能在很偶然的瞬间,脑子里突然浮现出了关于游戏的灵感。构思游戏题材的这个过程,其实是很有乐趣的。

在笔者漫无目的地寻找游戏的点子时,从设计师那里看到了一个角色形象。正是这个游戏的主人公——武士。问了之后才知道,除了角色之外,还有把怪物逐个砍倒的动画。就在那个瞬间,笔者萌发了“用这个角色来制作游戏”的想法。就这样,在和设计师深入交流后,制作这个游戏的念头就产生了。

决定游戏的内容时有一些要注意的事项。首先是操作简单。为了便于操作,我们只使用鼠标的一个按键。没有移动和跳跃操作,也没有复杂的手势输入。也许有些读者会觉得这种方式略显单调,不过如果能营造出点击按键时的韵律感,一定会是一款有趣的游戏。

还有一个要点是斩杀时的爽快感。画面上的大量怪物要夸张地向四处飞散。

游戏的场景大体如下页的插图所示。

这次我们从角色的形象出发构思了游戏的内容。灵感这东西说不定什么时候就会冒出来。一旦感觉到“这好像可以作成游戏呢!”就要把它记录下来,也许什么时候就能派上用场了(确实是这样的)。

1.2.1 脚本一览

文件

说明

SceneControl.cs

控制游戏整体
怪物的出现、攻击成功与失败的判断等

PlayerControl.cs

控制武士的行为

AttackColliderControl.cs

执行武士的攻击判断

FloorControl.cs

控制背景模型
将背景模型移动到武士周边

OniGroupControl.cs

控制怪物的分组
设定怪物以组为单位进行移动和碰撞检测

OniControl.cs

控制怪物的行为

OniEmitterControl.cs

得分时生成怪物

OniStillBodyControl.cs

得分时新产生的怪物

CameraControl.cs

控制摄像机

TitleSceneControl.cs

控制标题画面

{%}

1.2.2 本章小节

  • 无限滚动的背景

  • 无限滚动的背景的改良

  • 管理怪物出现的模式

  • 武士和怪物的碰撞检测

  • 得分高低的判定

  • 使怪物被砍中后四处飞散

1.3 无限滚动的背景 Tips

1.3.1 关联文件

  • FloorControl.cs

1.3.2 概要

在怪物这个游戏中,代表玩家的武士一直向右方前进,在游戏结束之前势必将移动非常远的距离。如果将所需要的背景全部做到一个模型中,那么数据量将非常大。而且还必须在游戏开始的时候就生成这些背景,非常麻烦。

在“怪物”这个游戏中,背景仅仅用于显示,和游戏的内容没有关系。即使重复出现同样的背景也不会影响游戏的内容。显示在画面中的也只局限于武士周围的一小部分而已。

既然这样,我们就可以反复利用几个相同的组件来合成背景,并且只在玩家的周边将各个组件逐个显示出来(图 1.1)。

图 1.1 背景的绘制

1.3.3 背景组件的显示位置

我们准备了三种类型的背景组件。分别是背景 A、背景 B 和背景 C(图 1.2)。

图 1.2 背景组件

通过循环并列显示 A、B、C 三种背景组件,就能够呈现出没有缝隙的背景。而实质上各个组件只有一个显示在了画面中。

刚开始时,A、B、C 各个组件都显示在武士的周围。当游戏开始武士移动了一定距离后,各个组件将移动到下一个合适的位置(图 1.3)。因为总共有三种组件,所以组件的移动宽度= 3× 一个组件的宽度。这个值在源代码中存放于变量 total_width 中。

图 1.3 移动背景组件

请在 Unity 中启动游戏,按下暂停按键切换到场景(Scene)视图。可以看到背景只显示在了武士的周围(图 1.4)。

{%}

图 1.4 背景组件移动示意图

把这个流程用代码描述出来,就是下面这样(从 FloorControl 类中摘出的一部分)。

FloorControl.Update 方法(摘要)

public static float WIDTH     = 10.0f * 4.0f;----------背景组件的宽度(X 轴方向)
public static int   MODEL_NUM = 3;---------------------背景组件的个数

void Update()
{
    整体背景(所有背景组件并列在一起)的宽度
    float   total_width     = FloorControl.WIDTH * FloorControl.MODEL_NUM;

    背景组件的位置
    Vector3 floor_position  = this.transform.position;

    摄像机的位置
    Vector3 camera_position = this.main_camera.transform.position;

    if(floor_position.x + total_width / 2.0f < camera_position.x) {-------(a) 摄像机超过下一个组件的中间点时往前移动
        // 往前移动
        floor_position.x += total_width;
        this.transform.position = floor_position;
    }
}

判断组件是否该移动的逻辑位于代码的 (a) 行。

floor_position 表示背景组件的位置,camera_position 表示摄像机的位置。虽然程序中使用了摄像机的位置来决定背景的移动,但为了便于理解,这里我们使用武士的位置来说明。由于武士位于画面中央偏左的位置,严格来说摄像机和武士的 X 坐标值并不相同。但是为了便于理解背景移动的算法,不妨认为这个等式成立:摄像机的 X 坐标=武士的 X 坐标

组件位于 floor_position.x 时,该组件再次出现时的坐标为“floor_position.x + total_width”。如果摄像机的 X 坐标大于中间点 floor_position.x + total_width / 2.0f,那么距离下次出现的位置比距离现在的位置更近,组件将移动到下一地点。

图 1.5 组件移动的时机

1.3.4 小结

这次我们介绍了无限循环的背景的基本制作方法。有些需要碰撞检测的游戏中会创建一个所谓的“地形”模型,但即便是在那种情况下,不影响游戏性的远景也常常通过这种循环显示背景组件的方法来表现。

本书中还有一些其他例子也使用了同样的方法,请读者自行参考。

1.4 无限滚动的背景的改良 Tips

1.4.1 关联文件

  • FloorControl.cs

1.4.2 概要

上节介绍的算法中,背景只在每次调用 Update() 时移动一次。这样一来,如果武士移动的距离很长,就有可能出现背景和角色的移动不和谐的情况(图1.6)。虽然我们这个游戏中并不会达到那样快的移动速度,但是有些游戏中出现过玩家角色移动,或者在场景中移动到错误位置的现象。下面就让我们来考虑一下这种情况的解决办法。

{%}

图 1.6 背景移动跟不上的情况

1.4.3 稍作尝试

让我们来实际体验一下上节所述的问题。作为调试,这里保留了武士能够一瞬间移动很远的距离这一功能。启动游戏后请按下 W 键。如图 1.7,可以看到在武士周围的背景不复存在了。

{%}

图 1.7 消失的背景

在 Unity 中执行时,将其切换到场景视图后使用逐帧模式观察,可以看到背景组件在缓缓移动。

在这种情况下,为了能够正确显示背景,该如何处理呢?

1.4.4 背景组件显示位置的改良

在前一小节中,我们提到了相同类型的背景组件会按照 total_width = 一个组件的宽带 × 组件数量(3 个)的间隔重复出现。也就是说,程序将从下列值中,

初始位置
初始位置+total_width×1
初始位置+total_width×2
初始位置+total_width×3
     ┇
初始位置+total_width×n n 为整数……(1)

选取一个最靠近武士坐标的值作为背景组件出现的位置。因此,只要求出上面算式 (1) 中 n 的值,就可以确定背景应该出现的位置。

接下来,让我们看看改良后的 FloorControl.Update 方法。

FloorControl.Update 方法(改良版、摘要)

void Update()
{
    float   total_width     = FloorControl.WIDTH * FloorControl.MODEL_NUM;
    Vector3 camera_position = this.main_camera.transform.position;
    float   dist            = camera_position.x - this.initial_position.x;
    int     n = Mathf.RoundToInt(dist / total_width);-------(a) 移动距离除以背景的整体宽度,再四舍五入

    Vector3 position = this.initial_position;
    position.x += n * total_width;-------------------(b) 背景组件将在total_width 的 n 倍距离位置出现
    this.transform.position = position;

}

在图 1.8 中,dist 是武士的移动距离。将 dist 除以背景组件的整体宽度 total_width 后的结果赋值给 n。

{%}

图 1.8 背景 A 出现的地方

n 是整数,但是经除法求出的结果并不一定是整数。因此把结果代入 n 之前,需要先做四舍五入处理。如果只是简单地进行类型转换(cast),将直接舍去小数部分,请读者注意这一点。

之所以使用四舍五入而不是直接舍去小数部分,是为了在舍去和进位两种情况中选取最靠近武士坐标的情况(图 1.9)。这种思路和上节提到的“越过中间点”的判断是一样的。

图 1.9 通过四舍五入,选择更接近的一端

可以使用 Mathf.RoundToInt 方法来实现四舍五入。Mathf 是 Unity 中的一个功能类。它含有很多基本的数学计算功能。

还有一种实现四舍五入的方法:让数字加上 0.5 后再舍去小数部分。可以利用 Mathf. FloorToInt 方法舍去小数部分。

Mathf.RoundToInt(dist / total_width);------------四舍五入
Mathf.FloorToInt(dist / total_width + 0.5f);-----加上 0.5 后舍去小数部分(效果等同于四舍五入)

使用 RoundToInt 和 FloorToInt 方法时,需要注意输入值为负数的情况。RoundToInt 直接对绝对值进行操作,而 FloorToInt 则会连同符号判断数值大小。

下表举例列出了分别用这两种方法对正负数进行操作的结果。

输入值

RoundToInt 结果

FloorToInt 结果

1.4

1

1

-1.4

-1

-2

在这个游戏中,武士的坐标只取正数。由于即使武士会往相反的方向移动也要判定“更近的一端”,因此程序中使用了 RoundToInt 方法。但是在某些游戏中,则可能需要找出“更靠近左边的一端”,这种情况下就应该使用 FloorToInt 方法。总之,我们应当依据不同的情况灵活选择最好的方法。

1.4.5 小结

到此为止,我们对循环显示背景组件的方法做了改良。不过大部分情况下,使用前一小节所介绍的方法就足够了,一般没有必要考虑怪物和武士的移动。开发过程中花太多精力在无关紧要的事情上容易造成本末倒置,不过在时间允许的情况下适当做一些有益的尝试,也许会对后续的开发很有帮助。请读者在明确“完成游戏”这一目标的同时,享受这种探索的乐趣。

1.5 怪物出现模式的管理 Tips

1.5.1 关联文件

  • LevelControl.cs

  • OniGroupControl.cs

1.5.2 概要

游戏启动后不久,画面右方将出现怪物(图 1.10)。游戏的目标是不停地砍倒怪物并持续前进。

{%}

图 1.10 武士和怪物

基本上,怪物只有跑向武士这一个动作。武士和怪物都沿直线跑动,玩家的操作仅仅是在合适的时机按下按键。非常简单。但是简单的操作绝不意味着游戏是无趣的。

在面向智能手机的游戏中,很多都仅仅支持点击屏幕操作。但其中大受玩家欢迎、百玩不腻的游戏也不占少数。

和这些游戏类似,我们这个游戏的精髓在于控制好点击按键的节奏。通过调整怪物出现的频率和速度,可以实现多种不同的情境。

下面让我们花些时间来设计怪物出现的方式,让游戏变得更有趣。

1.5.3 怪物出现的时间点

下面我们来看看该如何决定怪物出现的间隔。如果怪物相继出现的间隔很短,玩家就必须很快地点击按键,这样游戏就比较难。相反,如果怪物出现的间隔比较长,或者移动的速度比较慢,那么游戏就会比较简单。

首先考虑到怪物的运动速度和出现间隔=难易度,我们在每次成功攻击怪物后就增加游戏的难度。当然这里需要设置一个上限,并使出现失误后游戏会回到最初的状态。

每当武士前进了一定距离,怪物就将在其前方出现(图 1.11)。怪物出现的位置位于武士前,正好在画面之外即将进入画面的位置。如果这个距离过短画面上将会突然出现一个怪物。反之如果过长,则会导致在画面的渲染区域之外存在许多怪物,增加不必要的处理开销。

{%}

图 1.11 怪物出现的时间点

在设计怪物出现模式的时候,需算出武士从当前位置出发应当前进多远才让下一个怪物出现。如果玩家能很顺利地斩杀怪物,就让这个距离越来越短,而如果玩家出现失误,则恢复到最初的长度(图 1.12)。

{%}

图 1.12 怪物出现的间隔

实际试玩这个游戏后,就可以体会到随着怪物出现的间隔变短,游戏的难度也在渐渐增加。不恰当的速度或间隔的上限值可能将导致无法完全清除怪物,因此游戏开发者们要通过反复试玩来调整得出合适的值。

1.5.4 怪物出现模式的变化

虽然速度加快会导致游戏变难,不过反复试玩几次后玩家就会惊奇地发现自己完全能够适应了。即使是一开始觉得比较难的速度,经过几次挑战后似乎也变得没有什么了。这主要是因为目前怪物出现的间隔是固定值的缘故。

请读者想象一下和着音乐打拍子的情景。大家应该都有过这样的体会,即“即使节拍很快也跟上了”。其实两者的原理是一致的。因为不论速度多快,但节奏是相同的,所以玩家只需要按照同样的时间间隔点击按键就可以打倒怪物。

为了使玩家能够体会到点击按键的爽快感,要求游戏具备一定的节奏。不过如果一直使用同样的节奏,玩家很容易就可以消除怪物,这样游戏就显得有些无趣了。

也就是说,问题不在于速度,而在于节拍是固定的。那么我们试着每隔一段时间,就使用特别的出现模式。这里我们制作了以下几种和普通模式不一样的特别模式(图 1.13)。

{%}

图 1.13 有特色的怪物出现模式

  • 连续:怪物以短于正常时的时间间隔涌上来。

  • 缓慢:怪物移动的速度比一般模式的最低速度还慢,并且出现的间隔很长。玩家在持续玩难度较大的关卡时会感觉疲劳,因此可以插入这个模式供玩家休息调整。

  • 赶超:后出现的怪物追赶并超越更早出现的怪物。后登场的怪物将会更早到达武士的位置,这样会使玩家难于决定出手的时机,让其措手不及。可以说是这个游戏中比较难的一种模式。

  • 加速→减速:登场的怪物到达画面中央附近位置后加速,快要接近武士时减速,然后再朝武士前进。游戏的情景就好像伴随着“危险!快跑!”“不!这样不行!”这样的台词。相比用于控制游戏的难易度,这种模式更适合用于营造游戏的演示效果。

我们把这 4 种特别模式和普通模式混在一起来控制怪物的出现。每经过若干次普通模式后,就随机选择一种特别模式。普通模式的持续次数也通过随机决定(图 1.14)。

{%}

图 1.14 选择怪物出现模式的的流程

开始特别模式时,以及从特别模式恢复到普通模式时,必须确保画面中的怪物已经完全消失。这样可以防止特别模式和普通模式中的怪物同时出现。

比如我们看看在“加速→减速”模式中出现普通怪物的情况。当怪物在画面右边加速前进时如果出现了别的怪物,有时就会造成两个怪物以非常短的时间间隔到达武士的位置(图 1.15)。

{%}

图 1.15 怪物以非常短的时间间隔涌上来

为了避免这种情况,需要在使用特别模式前后等待一段时间,直到画面上的怪物“编队”完全消失。

那么,下面我们就来看看上述流程的实际代码吧。

LevelControl.oniAppearControl 方法(摘要)

public void oniAppearControl()
{

    (a) 检查是否准备好了生成新的怪物
    if(this.can_dispatch) {
    } else {------------------------(b) 还未准备好生成下一组怪物
        if(this.is_one_group_only()) {
            if(GameObject.FindGameObjectsWithTag("OniGroup").Length == 0) {------(b1)待画面内的怪物都消失后(如果找不到 OniGroup 对象),生成新的怪物
                this.can_dispatch = true;
            }
        } else {
            this.can_dispatch = true;----------(b2)普通模式时可以立刻生成
        }

        if(this.can_dispatch) {
            (c) 如果已准备好生成怪物,则通过玩家现在的位置计算出怪物的出现位置
            if(this.group_type_next == GROUP_TYPE.NORMAL) {
                this.oni_generate_line =
                    this.player.transform.position.x + this.next_line;
            } else {
                this.oni_generate_line =
                    this.player.transform.position.x + 10.0f;
            }
        }
    }

    // 玩家前进一定距离后,生成下一组怪物
    do {
        if(!this.can_dispatch) {
            break;
        }
        if(this.player.transform.position.x <= this.oni_generate_line) {
            break;
        }

        this.group_type = this.group_type_next;
        (d) 让怪物出现
        switch(this.group_type) {
            case GROUP_TYPE.SLOW:
            {
                this.dispatch_slow();
            }
            break;
            // (略)
        }

        this.can_dispatch = false;
        this.select_next_group_type();----------(e) 选择下次出现的怪物组
    } while(false);
}

(a)首先检查是否已经准备好了生成下一批怪物。就像前面所说明的那样,这是为了防止在特别模式中出现其他怪物。

(b)如果还没做好生成怪物的准备,需要检查现在和下一批怪物的出现模式是特别模式还是普通模式。当满足下列两种条件时,可以生成下一批怪物。

  (b1)当前为特别模式并且画面中已经没有怪物了

  (b2)当前为普通模式

(c)以现在的武士所在位置为基准,计算出怪物的生成位置。怪物从登场开始到消失为止,武士前进的距离随各模式不同而不同。因此,需要在准备好生成怪物的时候,就定好怪物将要产生的位置。

  计算出怪物的生成位置后,产生怪物的准备工作就完成了。这之后直到新的怪物被创建出来,(b)和(c)的处理都将被跳过。

(d)武士的位置超过 oni_generate_line 后生成新的怪物。

(e)最后,提前选择下一次将生成的怪物的类型。相对于在怪物生成后立刻选择下一批怪物的类型,程序在怪物从画面上消失时就要计算下一批怪物产生的位置。处理流程可能稍微有些复杂,请读者参考下面图 1.16 的程序流程图理解一下。

图 1.16 选择怪物出现模式的程序流程

1.5.5 小结

除了这里举例的 4 种模式之外,应该还有很多种算法。读者可以在理解了程序的结构原理后,试着自己创造一些有趣的模式。

1.6 武士和怪物的碰撞检测 Tips

1.6.1 关联文件

  • OniGroupControl.cs

1.6.2 概要

按下鼠标按键后武士将挥刀迎击,如果能成功砍到,怪物将向四处飞去。不过如果没有砍倒怪物却接触到了它,游戏则将结束。为了实现这种功能,需要检验武士对象和怪物对象之间的冲突,也就是所谓的碰撞(collision)检测处理。

在很多游戏中,碰撞检测是非常重要的一环,不过在程序处理方面往往是比较麻烦的。但是在 Unity 中,只需设定好形状就可以进行碰撞检测的计算。非常方便!

不过这并不意味着我们可以什么都不用考虑。使用何种形状来进行碰撞检测,将极大地影响游戏的效果。在这一点上,即使采用了 Unity,也仍旧需要依赖开发人员的经验和直觉。

1.6.3 分别对各个怪物进行碰撞检测的问题

首先我们尝试对武士采用立方体,对怪物采用球体来执行碰撞检测(图 1.17)。角色之间的碰撞检测,经常使用这种粗略的几何形状来进行。这样做的好处是相较于严格的几何形状,计算量会少很多。而且对于大部分游戏而言,这种做法都能得到比较逼真的结果。

{%}

图 1.17 武士和怪物的碰撞

一般来说,球体的计算量更小。因为游戏中会出现大量的怪物,所以我们选择了计算量尽可能小的球体作为检测形状。

设定好形状之后,就可以用 Unity 来执行碰撞检测的计算。然后再实现碰撞后武士的行为似乎就大功告成了。不过事实上等游戏运行起来以后,会发现还存在很多问题。

第一个问题是,武士会绕开怪物前进的问题(图 1.18)。

{%}

图 1.18 武士会绕开怪物前进的问题

这是因为怪物的碰撞检测形状比武士的小很多。在 Unity 的碰撞处理中,为了使对象在发生碰撞后仍可以按照原来的前进方向运动,对碰撞对象加入了滑动之类的处理。请回忆一下动作游戏中角色遇到墙壁时的反应。按照斜线方向一直按住方向键,大部分游戏中角色都将和墙壁发生摩擦继续移动。

让碰撞后的对象进行滑动,多数情况下都会使游戏玩起来更简单自然,不过在我们的这个游戏却带来了危害。

另外还有一个问题是,攻击的难度将加大(图 1.19)。

{%}

图 1.19 武士攻击难度加大的问题

点击鼠标按键后武士开始挥舞砍刀。攻击检测将伴随着这个攻击动作进行。和怪物的碰撞检测相似,我们也使用球体来进行检测。

虽然用于攻击检测的球体尺寸够大,不过从武士的中心位置往内或者往外偏移的地方有可能仍位于攻击范围之外。这样就会出现看起来好像攻击成功了其实却并未命中的情况。即使只剩下了一个怪物也有可能导致游戏以失败告终。像这样,如果稍微错过了时机就会造成失误的话,游戏就变得太难了。

针对这个问题,试着改变碰撞检测的形状及其尺寸也是一种解决方法。不过怪物自身的尺寸本来就很小,如果加大的话又将出现另外一个极端,即“看起来没有击中,结果却击中了”的情况可能会增多。

让我们试试其他的解决方法。

1.6.4 把怪物编成小组

游戏中的怪物总是扎堆出现。同一批次的怪物彼此之间比较密集,另外武士总是沿着直线前进。因此即使把同一批怪物编为一个小组来处理好像也没有问题。

于是我们用 OniGroup 对象把怪物集合起来,将怪物作为它的子元素来处理。碰撞检测也改为对 OniGroup 对象整体进行(图 1.20)。

{%}

图 1.20 怪物小组的碰撞检测

怪物小组的碰撞检测使用立方体,尺寸和武士的碰撞检测所用的立方体大抵相同(图 1.21)。这样设置以后,武士就不能再越过怪物或者从一侧绕过怪物了。

{%}

图 1.21 怪物小组在碰撞检测成功时

另外,假设 OniGroup 检测到来自武士的攻击后,作为子元素的所有怪物都将受到攻击。这样一来,前面提到的因为怪物的位置稍有差异就导致攻击不成功的情况就不会再有了。并且游戏也增添了多个怪物被同时砍飞的爽快感。

1.6.5 小结

Unity 中允许直接使用模型的形状来进行碰撞检测。不过很多游戏中都采用粗略的形状,而且也能达到像我们这次的游戏一样良好的效果。特别是那些尺寸比其他角色小很多的对象以及大量对象一起移动的情况下,把它们集中归为一个小组做碰撞检测是很好的方法。

1.7 得分高低的判定 Tips

1.7.1 概要

大部分游戏都鼓励玩家不断挑战更高的得分。虽然也有像角色扮演(RPG)这类更注重情节而不关注得分的游戏,不过支持玩家通过互联网与其他玩家同台竞技,进而挑战更高得分的游戏正变得越来越多。

这次我们开发的是一个斩杀怪物的游戏。成功斩杀怪物后,怪物出现的数量会越来越多。玩家要尽可能地持续斩杀怪物,这样才能在游戏结束之前杀掉大量的怪物。反之,玩家一旦失手,怪物出现的数量就会减少。

当然,我们可以直接把倒下的怪物数量作为玩家的得分,但这里我们不妨多琢磨一下,看看怎样才能让游戏更有趣。

例如,假若武士在追赶怪物的过程中一直不攻击怪物,最终将撞上怪物。在接近怪物的过程中,如果太近的话就会失手。玩家要在确保不撞上怪物的前提下尽可能地接近怪物并斩杀,这样游戏的技术难度就增加了。

这次我们设定了一个“靠近斩杀怪物将得到高分”的规则,但如果靠得太近又会导致武士撞上怪物而失手,这样游戏就变得更加刺激了。

如果觉得游戏缺点什么,或许可以尝试着往游戏中加入这种“高风险 & 高回报”的玩法。

{%}

图 1.22 靠近斩杀则得分较高

1.7.2 武士的攻击判定

在讨论如何判断攻击距离的远近之前,我们先说明一下攻击判定的原理。

玩家点击鼠标按键后,武士就会发起攻击行为。而攻击判定的计算就将伴随着攻击行为的整个过程。

在格斗类游戏中,往往需要对玩家角色的拳脚和所使用的武器执行攻击判定。同样,也需要对被攻击者的每个关节部位进行伤害计算。正因为有了这样精细的碰撞检测,才能够实现诸如“蹲下躲开对方的回旋踢”“脚部受到攻击而导致行走速度减缓”等游戏特性。

不过我们这个游戏的碰撞检测并不需要细致到这种程度。由于怪物以很快的速度朝武士靠近,如果要严格按照刀的形状来进行碰撞检测,击中的难度将大大增加。所以这里我们用一个大的球形来进行碰撞检测,并在播放攻击动作的时候将其放置在武士前面(图 1.23)。

图 1.23 武士攻击的碰撞检测

攻击的碰撞检测的执行时间,会比攻击动作的播放时间稍微长一些。如果提前按下鼠标按键,怪物就将进入上面所说的碰撞检测的球体中,这就意味着攻击成功(图 1.24)。格斗游戏中常常有“预判断”的说法。像这种敌人朝着玩家快速扑来的游戏,加入这种机制后会让游戏变得更容易上手。

{%}

图 1.24 怪物朝武士的攻击判定的球扑来

1.7.3 判断在多近的距离斩杀

通过提前进行碰撞检测和延长检测的时间,就可以应对怪物快速运动的情况。那么,现在让我们回到主题,看看如何才能计算出“武士在多近的距离斩杀了怪物”。

“要计算武士在多近的距离斩杀了怪物,看看怪物和武士之间的距离不就行了吗?”

如果这样想的话就错了!因为按照我们的设计思路,怪物会从外部撞向武士面前的碰撞检测用的球体,因此攻击成功时武士和怪物的距离必定等于该球体的半径(图 1.25)。

{%}

图 1.25 依据武士和怪物的距离进行判断的情况

那么下面就让我们来重新理解一下“靠近斩杀”这个过程吧。

攻击判定是在按键被按下的瞬间开始的。在这一瞬间,怪物和武士之间的距离可能很近,也可能很远。如果很远的话,怪物移动到碰撞检测的位置还需要一些时间。相反如果很近,碰撞检测很快就会进行。因此,我们只要计算出从执行碰撞检测(= 按下鼠标按键的瞬间)开始到实际发生碰撞为止经过的时间,应该就可以计算出是在多近的距离进行的斩杀了(图 1.26)。

{%}

图 1.26 通过攻击判定开始后的时间来判定的情况

我们用脚本 PlayerControl.cs 来管理攻击判定开始后的时间。

PlayerControl.attack_control 方法(摘要)

void attack_control()
{                           ┌----------------------------攻击判定执行中
    if(this.attack_timer > 0.0f) {
        this.attack_timer -= Time.deltaTime;------减少攻击判定执行的剩余时间

        if(this.attack_timer <= 0.0f) {-----------剩余时间为 0 时,攻击结束
            attack_collider.SetPowered(false);
        }                            └-------------关闭碰撞检测(攻击命中判定)功能
    } else {
        // (略)
    }
}

其中,attack_timer 是用来记录攻击判定持续时间的计时器。在按键被按下的瞬间会用一个特定的值对它进行初始化,随着时间的减少,当它的值变为 0 时,则意味着攻击判定执行结束。

attack_timer 表示的只是攻击判定的“剩余时间”,为了获得被用于判段“在多近的距离斩杀”的“经过时间”,我们还需要准备一个叫作 GetAttackTimer 的方法。

PlayControl.GetAttackTimer 方法

计算从攻击开始(点下鼠标按键开始)到现在所经过的时间
public float GetAttackTimer()
{
    return(PlayerControl.ATTACK_TIME - this.attack_timer);
}

SceneControl.cs 的 AddDefeatNum 方法在武士攻击命中怪物时被执行。

SceneControl.AddDefeatNum 方法(摘要)

public void AddDefeatNum(int num)
{
    this.attack_time =                             按下鼠标按键后经过的时间
        this.player.GetComponent<PlayerControl>().GetAttackTimer();

    if(this.attack_time < ATTACK_TIME_GREAT) {      -┐
        this.evaluation = EVALUATION.GREAT;          │
    } else if(this.attack_time < ATTACK_TIME_GOOD) { │
        this.evaluation = EVALUATION.GOOD;           ├--经过的时间越短成绩就越高
    } else {                                         │
        this.evaluation = EVALUATION.OKAY;           │
    }                                               -┘
}

最后,设定用于度量经过时间和得分高低关系的 ATTACK_TIME_GREAT 和 ATTACK_TIME_GOOD 的值。如果经过时间小于 ATTACK_TIME_GREAT 并在足够近的距离内斩杀了怪物则判定为 GREAT,若时间比 ATTACK_TIME_GOOD 短则判定为 GOOD,除此之外在远距离斩杀怪物的情况则判定为 OKAY。每次攻击后都记录下判定的结果,在游戏结束后再通过这些结果来决定玩家的总体成绩。

1.7.4 小结

通过改变用于衡量得分高低的经过时间的阈值(ATTACK_TIME_GREAT 和 ATTACK_TIME_GOOD),可以调整游戏中获得高分的难度。比起制作程序本身,有时候调整这些数值反而更花时间。但由于这些数值是决定游戏平衡性的重要因素,因此建议读者通过对照效果调整出最合适的数值。

1.8 使被砍中的怪物向四处飞散 Tips

1.8.1 概要

被武士砍中后,怪物将向四面八方飞散。

动作的不同将导致攻击力度的强弱表现不同,被攻击的各个对象的反应也有很大差异。在格斗游戏中,对对手一顿拳打脚踢之后,看到其步履蹒跚的样子,往往可以感受到他的疼痛。相反如果对手显得从容不迫,即使动作再华丽也只能给人一种攻击力很弱的印象。

有时候我们常常听到“攻击反馈”的说法。在玩游戏时大家应该都有过感觉按键和摇杆好像变重了的经历吧?可以说这种游戏通过视觉和听觉把攻击反馈非常完美地呈现了出来。

我们将通过怪物的四处飞散来表现武士的攻击强度。另外,我们也将实现上节提到的靠近斩杀怪物会获取高分的规则,并使“在多近的距离斩杀了怪物”影响怪物的飞散方式。

不过每次都采用同样的方式飞散开未免有些单调,因此我们会调整飞散的方向使每次的效果都略有不同。

图 1.27 怪物被砍中后向四周飞散

1.8.2 想象一下“圆锥体”

在考虑实现方法之前,我们首先整理一下“需要做什么”。用专业术语来说这叫作需求分析

  • 要让怪物华丽地四处飞散

  • 让每次的动作都各不相同

“华丽”这种描述对于编程来说是一个比较暧昧的说法。应该描述得更为具体一些。

前面我们已经提到过把若干个怪物编成一个小组,并通过这个小组来执行被攻击判定。受到攻击时小组内的所有怪物都将四处飞散。而如果怪物们都向着同样的方向飞去,将毫无“华丽”可言。换句话说,所谓的“华丽”,应该是这些怪物尽量朝着不同的方向飞散开来。

这个被刀砍中然后各自飞散开的过程,更类似于炸弹爆炸的画面。由于怪物被刀砍中时受到了某一方向的作用力,因此往相反的一侧飞出才显得自然。武士具有右斩、左斩的动作。每个动作都将令怪物向反方向飞出。

“靠近斩杀时怪物将更华丽地飞散开”这个要素也是必要的。虽然单纯改变速度也能达到类似的效果,但为了让玩家更容易地了解是否完美地砍中了怪物,我们将飞散的方向改为前后方向。如果从前面飞来的怪物都按照相同的方向弹开,就能让玩家强烈地感受到攻击的力度。

那么我们再次细化需要完成的工作。

  • 怪物朝不同方向飞出

  • 根据动作的不同往左或往右飞出

  • 根据斩杀时距离的远近调整为前后方向

  • 每次飞出的方式都有变化

要是每次飞出的方式都不一样,很多读者可能会想到使用随机数。不过如果仅对飞出的方向和速度进行随机化处理,虽然可以改变飞出的方向,但是不能够保证怪物会按我们期待的方向飞出。

像这样“想在随机化的同时进行某种程度的倾向控制”的时候,解决问题的关键就是先确定好关键性的原则,再使用随机数改变细节参数

这里我们参考水管喷头喷水的情景,决定使怪物沿着圆锥的表面飞出,也就是说圆锥的朝向基本上决定了飞散的方向,底面的半径则决定了飞散开的范围(图 1.28)。

{%}

图 1.28 圆锥的朝向大体决定了飞散的方向

1.8.3 具体的计算方法

接下来,我们对各个参数进行详细的说明。首先看看圆锥的底面半径如何决定了飞散的范围(图 1.29)。

{%}

图 1.29 底面半径决定了飞散开的范围

怪物被砍中后飞出的方向是由武士攻击瞬间的速度向量决定的。如图 1.29 所示,所有怪物的速度向量都以圆锥的顶点为起始点,终点位于圆锥底面的圆周上,并按一定间隔并列排开。

底面半径越大圆锥的开口范围越广,每个怪物的速度向量的方向也有很大差异,因此怪物的飞散范围就比较广。反之如果半径比较小,则飞散开的范围就比较窄。

下面,我们通过圆锥的倾角来控制前后方向(图 1.30)。

{%}

图 1.30 圆锥倾斜的角度决定了飞散开的前后方向

这里的“前后”,指的是从武士的视角看到的前后。武士向画面右方前进,也就是 +X 方向,这样在画面上看起来就是左右倾斜。需要注意的是在计算时会变为围绕 Z 轴(Vector3.forward)旋转。

最后,通过圆弧的中心角度来控制左右方向的飞散(图 1.31)。

{%}

图 1.31 圆弧的中心角度决定了左右方向

怪物飞散的方向,也就是速度向量分布在圆锥的表面上。但是它们并没有完全分布在 1 周 360 度的各个角度,而是集中在了大约半个圆周的范围内。这里将通过排列着各个速度向量的圆弧(图 1.31 中两端是黑点的圆弧)的中心点的角度控制左右方向。程序中使用 y_angle_swing 变量来表示。

下面我们结合代码来看看实际的计算过程。

OniGroupControl.OnAttackedFromPlayer 方法(摘要)

public void OnAttackedFromPlayer()
{
    blowout_up = Vector3.up;-------------(a) 圆锥的中心轴(朝上方向)的向量
    blowout_xz = Vector3.right * base_radius;------------(b) 底面中心到圆周方向的向量
    blowout_xz = Quaternion.AngleAxis(y_angle, Vector3.up) * blowout_xz;-------(c) blowout_xz 绕Y 轴旋转

    blowout = blowout_up + blowout_xz;----(d) blowout= 圆锥表面的向量
    blowout.Normalize();
    blowout = Quaternion.AngleAxis(--------(e) 相当于使圆锥前后倾斜
        forward_back_angle, Vector3.forward) * blowout;

    // 飞散的速度
    blowout_speed = blowout_speed_base * Random.Range(0.8f, 1.2f);

    blowout *= blowout_speed;---------------(f) 乘以速度值
}

(a)将 blowout_up 设为朝上方向的向量。这是圆锥的中心轴,圆锥的初始状态为直立,且顶点在下。

(b)blowout_xz 为底面中心指向圆周方向的向量。base_radius 为圆锥的底面的半径,这个值决定了怪物飞散开的范围的大小。

(c)使 blowout_xz 向量绕 Y 轴旋转。y_angle 是将小组整体的圆弧以 y_angle_swing 为中心按怪物数量平均分割后得到的角度值。

(d)blowout_up、blowout_xz 分别为初始速度向量的垂直和水平分量。这里将二者相加。因为最后要将此向量和速度相乘,所以事先用 Normalize() 进行规范化。

(e)使求出的向量围绕 Z 轴 (Vector3.forward) 旋转。这相当于将圆锥向前后倾斜。

(f)将向量与速度值相乘得到最后结果。

程序中的参数以及各个步骤(a)~(f)和圆锥的对应关系如图 1.32 所示。

{%}

图 1.32 程序中的各个步骤与圆锥的对应关系

1.8.4 小结

实际的游戏中会使用随机化的参数,使每次的结果都略有不同。像这样先确定好大原则后再用随机数对细节参数进行调整的方法,是游戏开发中经常用到的技巧。

至于该使用什么样的大原则则因游戏而异。这次游戏中使用的是圆锥体,地面上发生爆炸时可以采用半球,2D 游戏中可以使用扇形等。请读者务必尝试一下这些方法来加深理解。

目录