游戏AI:机器人反击!

以下是摘自Earle Castledine撰写的新书HTML5 Games:Ninja的新手 这本书的访问权限包含在SitePoint Premium会员资格中,或者您可以在世界各地的商店中索取一份副本。 您可以在此处查看第一章免费样本

现在,我们拥有所有可用的工具,可以制作出令人难以置信的详细世界来探索和居住。 不幸的是,我们的同居者并没有证明自己是非常值得的对手。 他们很愚蠢:他们没有情感,没有思想,没有生气 。 我们可以通过图形,动画,尤其是人工智能(AI)来灌输这些特征。

人工智能是一个巨大且极其复杂的领域。 幸运的是,即使是比人工智能更多的人为因素 ,我们也可以获得令人印象深刻的结果。 几个简单的规则(与我们的老朋友Math.random结合使用)可以给人以通透的意图和思想错觉。 只要它支持我们的游戏机制并且很有趣,就不必过于现实。

像碰撞检测一样,当AI 不太好时,它通常是最好的。 电脑的对手是超人的。 他们拥有无所不知的天赋,可以在每个时间点理解整个世界。 这位可怜的老人类玩家只能够看到什么是显示在屏幕上。 它们通常无法与计算机匹敌。

但是我们不让他们知道! 他们会感到难过,质疑人类的未来,不想玩我们的游戏。 作为游戏设计师,平衡和支配我们的游戏流程是我们的工作,以使它们始终对玩家公平,富有挑战性且令人惊讶。

故意运动

选择精灵在游戏中如何移动非常有趣。 update功能是您的空白画布,您可以对实体进行神似的控制。 那不喜欢什么!

实体移动的方式取决于我们每帧改变其xy位置的数量(“稍微移动一点!”)。 到目前为止,我们主要通过pos.x += speed * dt直线移动了pos.x += speed * dt 。 增加速度(乘以增量)会导致精灵向右移动。 减法将其向左移动。 更改y坐标可上下移动。

为了使直线更有趣,请注入一些三角函数。 使用pos.y += Math.sin(t * 10) * 200 * dt ,子画面通过正弦波上下摆动。 t * 10是波的频率。 t是我们更新系统中的时间(以秒为单位),因此它总是线性增加。 将其Math.sin会产生平滑的正弦波。 改变倍频会改变频率:数字越小振荡越快。 200是波的振幅

您可以组合波浪以获得更有趣的结果。 假设您在y位置添加了另一个正弦波: pos.y += Math.sin(t * 11) * 200 * dt 。 它几乎与第一个完全相同,但是频率变化很小。 现在,由于这两个波在相位上移入和移出时相互增强和抵消,因此实体会越来越快地上下摆动。 大量改变频率和幅度会产生一些有趣的反弹模式。 使用Math.cos更改x位置,您将有一个圆圈。

重要的方面是,可以将动作组合起来以做出看起来更复杂的行为。 他们可以痉挛地移动,可以懒惰地漂移。 当我们阅读本章时,他们将能够直接向玩家充电或直接逃跑。 他们将能够穿越迷宫。 当您结合使用这些技能(将弹跳动作与玩家的冲刺动作结合使用)或对它们进行排序(逃跑两秒钟,然后上下摆动一秒钟)时,它们可以被雕刻成栩栩如生的生物。

航点

我们需要给这些冷漠的幽灵和蝙蝠加些香料,给它们一些生活的空间。 我们将从“航路点”的概念开始。 航点是实体将要到达的里程碑或中间目标位置。 一旦到达航路点,便继续前进到下一个路点,直到到达目的地。 精心放置的一组航点可以为游戏角色提供一种目标感,并可以在关卡设计中发挥巨大作用。

佛朗哥·庞蒂切利的FlyMaze航路点炸弹

为了使我们能够集中精力于航点背后的概念,我们将介绍一个不受迷宫墙约束的飞行坏人。 飞行中最可怕的敌人是蚊子(蚊子是仅次于人类的世界上最致命的动物)。 但不是很诡异 。 我们将使用“蝙蝠”。

蝙蝠不会是复杂的野兽。 他们将是不可预测的。 他们只会有一个飞向的航路点。 当他们到达那里时,他们会选择一个新的航路点。 稍后(当我们穿越迷宫时),我们将介绍具有多个结构化的航路点。 就目前而言,蝙蝠从一个点到另一个点飘荡,通常对玩家是个麻烦。

要创建它们,请在entities/Bat.js基于TileSprite创建一个名为Bat的新实体。 蝙蝠需要一些聪明才智来选择所需的航路点。 这可能是挑选在屏幕上任意位置的任意位置的功能,反而使他们更加强大一点,我们将给予他们findFreeSpot功能,所以航点永远是一个适宜步行的瓷砖,玩家可能会旅行:

const bats = this.add(new Container());
for (let i = 0; i < 5; i++) {
  bats.add(new Bat(() => map.findFreeSpot()))
}

我们有一个新的蝙蝠Container ,并创建了五个新的蝙蝠。 每个人都可以参考我们的航点选择功能。 调用时,它将运行map.findFreeSpot并在迷宫中找到一个空单元格。 这成为蝙蝠的新航路点:

class Bat extends TileSprite {
  constructor(findWaypoint) {
    super(texture, 48, 48);
    this.findWaypoint = findWaypoint;
    this.waypoint = findWaypoint();
    ...
  }
}

Bat.js我们分配一个初始目标位置,然后在bat的update方法中向其移动。 足够接近后,我们选择另一个位置作为下一个航路点:

// Move in the direction of the path
const xo = waypoint.x - pos.x;
const yo = waypoint.y - pos.y;
const step = speed * dt;
const xIsClose = Math.abs(xo) <= step;
const yIsClose = Math.abs(yo) <= step;

我们如何“走向”某事物,以及我们如何知道自己是否“足够接近”? 要回答这两个问题,我们首先要找到航点位置和蝙蝠之间的区别。 从蝙蝠的位置减去航路点的xy值,便可以得出每个轴上的距离。 对于每个轴,我们定义“足够接近”以表示Math.abs(distance) <= step 。 使用step (基于speed )意味着我们行进得越快,我们就需要离“足够近”(以便我们永远不会超调)。

注意:取距离的绝对值,因为如果我们在航路点的另一侧,则它可能为负。 我们不在乎方向,只在乎距离。

if (!xIsClose) {
  pos.x += speed * (xo > 0 ? 1 : -1) * dt;
}
if (!yIsClose) {
  pos.y += speed * (yo > 0 ? 1 : -1) * dt;
}

为了朝着航路点的方向移动,我们将移动分为两部分。 如果我们在xy方向上都不太靠近,则将实体移向航路点。 如果重影位于航路点( y > 0 )上方,则将其向下移动,否则将其向上移动-与x轴相同。 这不会给我们一条直线(当我们开始向玩家射击时会出现一条直线),但是它确实使我们更接近每一帧的航路点。

if (xIsClose && yIsClose) {
  // New way point
  this.waypoint = this.findWaypoint();
}

最后,如果水平距离和垂直距离都足够近,则表明蝙蝠已经到达目的地,我们将this.waypoint重新分配给新位置。 现在,蝙蝠像我们可能期望的那样,无意识地在大厅里漫游。

这是一个非常简单的航点系统。 通常,您需要一个构成完整路径的点列表。 当实体到达第一个航点时,它将从列表中拉出,下一个航点取而代之。 当我们很快遇到寻路时,我们将做与此类似的事情。

向目标移动并射击

回想一下第3章中的第一个射击游戏。坏人只是从右向左飞来飞去,注意他们自己的事,而我们这些球员则在嘲笑那些毫无头脑的僵尸飞行员。 为了使游戏环境平整并使游戏玩法更有趣,我们的敌人至少应该能够向我们发射弹丸 。 这给玩家提供了在屏幕上四处移动的动机,以及消灭原本相当和平的实体的动机。 突然我们又成为英雄了。

向坏人提供玩家位置的感知非常容易:这只是player.pos ! 但是,我们如何使用这些信息将事物发送到特定的方向呢? 答案当然是三角函数!

function angle (a, b) {
  const dx = a.x - b.x;
  const dy = a.y - b.y;
  const angle = Math.atan2(dy, dx);

  return angle;
}

注意:在本章中,我们将看到几个三角函数,用于实现我们的“更好的坏人”的近期目标-但我们将不会真正探讨它们的工作原理。 这是下一章的主题……因此,如果您对数学有些生疏,可以暂时放松一下。

以我们实现math.distance的相同方式,我们首先需要获取两个点( dxdy )之间的 ,然后使用内置的反正切数学运算符Math.atan2来获取两个向量之间创建的角度。 请注意, atan2y差作为第一个参数,将x用作第二个参数。 将angle函数添加到utils/math.js

在我们的游戏中,大多数时候,我们都会寻找两个实体之间的夹角(而不是点)。 因此,我们通常对实体中心之间的角度感兴趣,而不是由pos定义的实体左上角。 我们还可以向utils/entity.js添加一个angle函数,该函数首先找到两个实体的中心, 然后调用math.angle

function angle(a, b) {
  return math.angle(center(a), center(b));
}

angle函数以弧度返回两个位置之间的角度。 现在,利用这些信息,我们可以计算出修改实体的xy位置以朝正确方向移动的数量:

const angleToPlayer = entity.angle(player.pos, baddie.pos);
pos.x += Math.cos(angle) * speed * dt;
pos.y += Math.sin(angle) * speed * dt;

要在游戏中使用角度,请记住,角度的余弦是在角度方向上移动一个像素时需要沿x轴移动的距离。 角度的正弦是您需要沿着y轴移动多远。 乘以标量( speed )像素数,子画面会朝正确的方向移动。

在游戏开发中,了解两件事之间的夹角非常重要。 将该方程式存储到内存中,因为您会经常使用它。 例如,我们现在可以直接事物开枪-让我们开始吧! 创建一个Bullet.js精灵以充当弹丸:

class Bullet extends Sprite {
  constructor(dir, speed = 100) {
    super(texture);
    this.speed = speed;
    this.dir = dir;
    this.life = 3;
  }
}

Bullet是一个小的精灵,它是由一个位置,一个速度(速度和方向)和一个“生命”(默认为三秒)创建的。 当生命变为零时,子弹将被设置为dead弹……而我们最终将不会获得数百万发向无限远的子弹(就像我们第3章中的子弹一样)。

update(dt) {
  const { pos, speed, dir } = this;

  // Move in the direction of the path
  pos.x += speed * dt * dir.x;
  pos.y += speed * dt * dir.y;

  if ((this.life -= dt) < 0) {
    this.dead = true;
  }
}

与我们的第3章项目符号的不同之处在于,它们现在沿实例化时给定的方向移动。 因为xy代表两个实体之间的角度,所以子弹将以直线向目标射击 -就是我们。

子弹不仅会神秘地凭空出现。 需要解雇他们。 我们需要另一个新的坏蛋! 我们将以礼帽图腾的形式部署几个哨兵。 图腾是地牢的守护者,他们从迷宫的中心监视世界,摧毁了所有盗窃宝藏的主角。

高帽图腾:Toptems。

Totem.js实体生成Bullets并将其向Player发射。 因此,他们需要引用玩家(他们不知道它是玩家,他们只是将其视为target ),并且需要一个在生成子弹时调用的函数。 我们将其称为onFire并将其从onFire传递GameScreen因此Totem不必担心Bullets

class Totem extends TileSprite {
  constructor(target, onFire) {
    super(texture, 48, 48);
    this.target = target;
    this.onFire = onFire;
    this.fireIn = 0;
  }
}

创建新的Totem ,会为其分配一个目标,并为它发射Bullet时提供调用功能。 该功能会将子弹添加到主游戏容器中,以便可以检查是否有碰撞。 现在,勇敢者必须避开Bats Bullets 。 我们将容器重命名为baddies因为两者的碰撞逻辑是相同的:

new Totem(player, bullet => baddies.add(bullet)))

要在屏幕上显示实体,需要将其放入Container以包含在场景图中。 我们有很多方法可以做到这一点。 我们可以使我们的主要GameScreen对象成为全局变量, gameScreen.add从任何地方调用gameScreen.add 。 这可以工作,但是不利于信息封装。 通过传递函数,我们可以指定我们希望Totem执行的功能。 与往常一样,最终取决于您。

警告:我们的Container逻辑中有一个隐藏的陷阱。 如果我们在该容器自己的update调用期间将一个实体添加到该容器中,则不会添加该实体! 例如,如果Totem在里面baddies ,并试图还添加了一个新的子弹baddies ,会不会出现子弹。 查看Container的代码,看看是否可以理解原因。 我们将在第9章的“遍历数组”中解决此问题。

图腾何时应该向玩家射击? 当然是随机的! 在拍摄时, fireIn变量将设置为倒计时。 倒计时发生时,图腾具有较小的动画(在两个帧之间切换)。 在游戏设计中,这称为“ 电报” -一种向玩家微妙的视觉指示 ,表明他们最好保持警惕。 如果不进行电报,我们的图腾会突然随机地向玩家射击,即使它们确实很接近。 他们没有机会躲避子弹,会感到被欺骗和烦恼。

if (math.randOneIn(250)) {
  this.fireIn = 1;
}
if (this.fireIn > 0) {
  this.fireIn -= dt;
  // Telegraph to the player
  this.frame.x = [2, 4][Math.floor(t / 0.1) % 2];
  if (this.fireIn < 0) {
    this.fireAtTarget();
  }
}

图腾发射的每一帧都有250分之一的机会。 如果是这样,倒数计时将开始一秒钟。 倒数之后, fireAtTarget方法将进行艰苦的工作来计算弹丸击中目标所需的轨迹:

fireAtTarget() {
  const { target, onFire } = this;
  const totemPos = entity.center(this);
  const targetPos = entity.center(target);
  const angle = math.angle(targetPos, totemPos);
  ...
}

第一步是使用math.angle获取目标和图腾之间的角度。 我们可以使用帮助器entity.angle (由entity.center来完成),但是我们还需要图腾的中心位置来正确设置项目符号的起始位置:

const x = Math.cos(angle);
const y = Math.sin(angle);
const bullet = new Bullet({ x, y }, 300);
bullet.pos.x = totemPos.x - bullet.w / 2;
bullet.pos.y = totemPos.y - bullet.h / 2;

onFire(bullet);

一旦有了角度,就可以使用余弦和正弦来计算方向的分量。 (再次,嗯:也许您想把它变成另一个对您有用的数学函数?)然后我们创建一个新的Bullet ,它将沿正确的方向移动。

这突然使迷宫遍历变得非常具有挑战性! 您应该花一些时间来尝试“射击”代码:更改随机间隔的机会,或者将其设置为每两秒钟持续触发一次的计时器……或者是会短暂发射子弹的子弹头生成器一段的时间。

注意:在本书中,我们已经看到许多说明各种概念的小型机制。 不要忘记游戏机制很灵活。 它们可以重复使用,并与其他机制,控件或图形重新组合,以产生更多的游戏创意和游戏类型! 例如,如果您将“鼠标单击”与“航路点”和“朝…射击”结合使用,我们将提供基本的塔防游戏! 创建一个供敌人遵循的航路点路径:单击鼠标可添加一个炮塔(使用math.distance查找最接近的敌人),然后向其发射。

聪明的坏蛋:攻击和规避

我们的坏蛋们一心一意。 给他们一个简单的任务(随机射击时向左飞;向玩家射击……),并且他们永久地做同样的事情,就像一些盲目的自动机一样。 但是真正的坏蛋不是那样的:他们计划,徘徊,闲置,处于各种戒备状态,攻击,撤退,停下来吃冰淇淋……

Mozilla的BrowserQuest中工作和休息的骨骼

为这些需求建模的一种方法是通过状态机状态机协调行为在一定数量的状态之间的变化。 不同的事件可能会导致从当前状态到新状态的转变状态将是特定于游戏的行为,例如“闲置”,“行走”,“攻击”,“停止吃冰淇淋”。 您不能攻击停下来喝冰淇淋。 实现状态机就像存储状态变量一样简单,我们将状态变量限制为列表中的一项。 这是我们可能的蝙蝠状态的初始列表(在Bat.js文件中定义):

const states = {
  ATTACK: 0,
  EVADE: 1,
  WANDER: 2
};

注意:不必在这样的对象中定义状态。 我们可以只使用字符串“ ATTACK”,“ EVADE”和“ WANDER”。 使用这样的对象只会使我们组织思想-在一个位置列出所有可能的状态-并且如果我们犯了错误(例如分配不存在的状态),我们的工具可以警告我们。 字符串很好!

蝙蝠在任何时候都只能处于ATTACKEVADEWANDER状态之一。 攻击将在玩家身上进行 ,逃避是在直接远离玩家的地方飞行,而游荡则随机在周围飞来飞去。 在函数构造函数中,我们将分配ATTACK的初始状态: this.state = state.ATTACK 。 在update内部,我们根据当前状态切换行为:

const angle = entity.angle(target, this);
const distance = entity.distance(target, this);

if (state === states.ATTACK) {
  ...
} else if (state === states.EVADE) {
  ...
} else if (state === states.WANDER) {
  ...
}

根据当前状态(并结合与玩家的距离和角度), Bat可以决定其行为方式。 例如,如果在进攻,​​它可以直接向玩家移动:

xo = Math.cos(angle) * speed * dt;
yo = Math.sin(angle) * speed * dt;
if (distance < 60) {
  this.state = states.EVADE;
}

但是事实证明,我们的蝙蝠就像鸡一样:当它们离目标太近(60像素以内)时,状态切换为state.EVADE 。 躲避与攻击相同,但是我们忽略了速度,因此它们直接飞离玩家:

xo = -Math.cos(angle) * speed * dt;
yo = -Math.sin(angle) * speed * dt;
if (distance > 120) {
  if (math.randOneIn(2)) {
    this.state = states.WANDER;
    this.waypoint = findFreeSpot();
  } else {
    this.state = states.ATTACK;
  }
}

避开时,蝙蝠不断考虑下一步行动。 如果距离播放器足够远,无法感到安全(120像素),它将重新评估其状况。 也许它想再次进攻,或者它想向随机的航路点走去。

蝙蝠袭击时

以这种方式组合和排序行为是在游戏中制作真实可信的角色的关键。 当各种实体的状态机受其他实体的状态影响而导致紧急行为时,可能会变得更加有趣。 这是当实体的明显特征神奇地出现的时候,即使您(作为程序员)并未专门设计它们。

注意:在Minecraft中就是一个例子。 动物受伤害后可以逃避。 如果您攻击一头母牛,它将持续一生(因此,狩猎对玩家而言更具挑战性)。 游戏中的狼也具有攻击状态(因为它们是狼)。 这些状态机的意外结果是,您有时会看到狼参与快节奏的狩猎! 没有明确添加此行为,但是由于合并系统而出现。

更庄重的状态机

编排游戏时,不仅会在实体AI中使用状态机,还会使用很多状态机。 他们可以控制屏幕的显示时间(例如“准备就绪!”对话框),设置游戏的节奏和规则(例如管理冷却时间和计数器),并且对于将任何复杂的行为分解为细小,可重复使用的片段。 (处于不同状态的功能可以由不同类型的实体共享。)

使用自变量处理所有这些状态, if … else条款可能变得笨拙。 一种更强大的方法是将状态机抽象到其自己的类中,该类可通过其他功能重用和扩展(例如,记住我们之前所处的状态)。 这将在我们制作的大多数游戏中使用,因此让我们为其创建一个名为State.js的新文件,并将其添加到Pop库中:

class State {
  constructor(state) {
    this.set(state);
  }

  set(state) {
    this.last = this.state;
    this.state = state;
    this.time = 0;
    this.justSetState = true;
  }

  update(dt) {
    this.first = this.justSetState;
    this.justSetState = false;
    ...
  }
}

State类将保存当前和以前的状态,并记住我们进入当前状态已经有多长时间了。 它还可以告诉我们这是否是我们进入当前状态的第一帧。 它通过一个标志( justSetState )来实现。 在每一帧中,我们都必须更新state对象(使用MouseControls方法相同),以便可以进行时序计算。 在这里,如果它是第一次更新,我们还将设置第first标志。 这对于执行状态初始化任务(例如重置计数器)很有用。

if (state.first) {
  // just entered this state!
  this.spawnEnemy();
}

当状态被设置(通过state.set("ATTACK")则属性first将被设置为true 。 随后的更新会将标志重置为false 。 增量时间也会传递给update因此我们可以跟踪当前状态处于活动状态的时间。 如果是第一帧,我们将时间重设为0; 否则,我们添加dt

this.time += this.first ? 0 : dt;

现在,我们可以改写我们的追逐逃逸示例以使用状态机,并删除if的嵌套:

switch (state.get()) {
  case states.ATTACK:
    break;
  case states.EVADE:
    break;
  case states.WANDER:
    break;
}
state.update(dt);

对于Bat大脑来说 ,这是一些不错的文档-根据当前的输入来决定下一步要做什么。 因为状态的first帧有一个标志,所以现在还有个添加任何初始化任务的好地方。 例如,当Bat开始进行WANDER ,它需要选择一个新的航点位置:

case states.WANDER:
  if (state.first) {
    this.waypoint = findFreeSpot();
  }
  ...
  break;
}

它通常是一个好主意,做初始化任务在state.first框架,而不是当你转换前一帧的出来 。 例如,我们可以像设置state.set("WANDER")一样设置路标。 如果状态逻辑是独立的,则测试起来会更容易。 我们可以将Bat 默认设置this.state = state.WANDER并且知道航点将在更新的第一帧中设置。

我们将添加到State.js中的一些其他便捷函数来查询当前状态:

is(state) {
  return this.state === state;
}

isIn(...states) {
  return states.some(s => this.is(s));
}

使用这些帮助器功能,我们可以方便地确定我们是否处于一种或多种状态:

if (state.isIn("EVADE", "WANDER")) {
  // Evading or wandering - but not attacking.
}

我们为实体选择的状态可以根据需要进行细化。 我们可能具有“ BORN”(首次创建实体时),“ DYING”(当其被击中并被击晕时)和“ DEAD”(当其结束时)的状态,为我们提供了在类中用于处理逻辑的离散位置和动画代码。

控制游戏流程

需要控制动作流程的任何地方,状态机都非常有用。 一种出色的应用程序是管理我们的高级游戏状态。 当地牢游戏开始时,不应将用户扔进忙乱的怪物和子弹四处飞来飞去的冲击。 取而代之的是,出现友好的“ READY READY”消息,让玩家有几秒钟的时间来调查情况并为即将到来的混乱做好心理准备。

状态机可以将GameScreen更新中的主要逻辑分解为“ READY”,“ PLAYING”,“ GAMEOVER”之类的内容。 它使我们更清楚如何构造代码以及整个游戏流程将变得更加清晰。 不需要处理update功能中的所有内容; switch语句可以调度到其他方法。 例如,所有用于“ PLAYING”状态的代码都可以归为一个updatePlaying函数:

switch(state.get()) {
  case "READY":
    if (state.first) {
      this.scoreText.text = "GET READY";
    }
    if (state.time > 2) {
      state.set("PLAYING");
    }
    break;

  case "PLAYING":
    if (entity.hit(player, bat)) {
      state.set("GAMEOVER");
    }
    break;

  case "GAMEOVER":
    if (controls.action) {
      state.set("READY");
    }
    break;
}
state.update(dt);

GameScreen将以READY状态启动,并显示消息“ GET READY”。 两秒钟后( state.time > 2 ),它过渡到“ PLAYING”,游戏开始。 当玩家被击中时,状态会移至“ GAMEOVER”,在这里我们可以等到按下空格键再重新开始。

From: https://www.sitepoint.com/game-ai-the-bots-strike-back/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值