创建一个伪3D赛车游戏:实现丘陵并完成游戏

第3部分。丘陵



在上一部分中,我们创建了一个简单的伪三维赛车游戏,在其中实现了直线道路和弯道。

这次我们要照顾山丘;幸运的是,这比创建弯曲的道路要容易得多。

在第一部分中,我们使用相似三角形的定律创建了三维透视投影:


...导致我们获得了将3d世界的坐标投影到2d屏幕的坐标的方程式。


...但是从那时起,我们只使用直行道路,因此世界坐标只需要z分量,因为xy都等于零。

这很适合我们,因为添加山足以为路段赋予对应的非零坐标y,然后现有功能将render()神奇地起作用。


是的,这足以使人上山。只需将y分量添加到每个路段的世界坐标

道路几何形状的变化


我们将修改现有方法,addSegment以使调用它的函数可以传递p2.world.y,而p1.world.y将与上一段的p2.world.y相对应

function addSegment(curve, y) {
  var n = segments.length;
  segments.push({
     index: n,
        p1: { world: { y: lastY(), z:  n   *segmentLength }, camera: {}, screen: {} },
        p2: { world: { y: y,       z: (n+1)*segmentLength }, camera: {}, screen: {} },
     curve: curve,
     color: Math.floor(n/rumbleLength)%2 ? COLORS.DARK : COLORS.LIGHT
  });
}

function lastY() {
  return (segments.length == 0) ? 0 : segments[segments.length-1].p2.world.y;
}

添加常数以表示低(LOW),中(MEDIUM)和高(HIGH)坡度:

var ROAD = {
  LENGTH: { NONE: 0, SHORT:  25, MEDIUM:  50, LONG:  100 },
  HILL:   { NONE: 0, LOW:    20, MEDIUM:  40, HIGH:   60 },
  CURVE:  { NONE: 0, EASY:    2, MEDIUM:   4, HARD:    6 }
};

更改现有方法addRoad(),使其接收参数y,该参数将与平滑函数一起用于从山上逐渐上升和下降的情况:

function addRoad(enter, hold, leave, curve, y) {
  var startY   = lastY();
  var endY     = startY + (Util.toInt(y, 0) * segmentLength);
  var n, total = enter + hold + leave;
  for(n = 0 ; n < enter ; n++)
    addSegment(Util.easeIn(0, curve, n/enter), Util.easeInOut(startY, endY, n/total));
  for(n = 0 ; n < hold  ; n++)
    addSegment(curve, Util.easeInOut(startY, endY, (enter+n)/total));
  for(n = 0 ; n < leave ; n++)
    addSegment(Util.easeInOut(curve, 0, n/leave), Util.easeInOut(startY, endY, (enter+hold+n)/total));
}

此外,类似于我们在第2部分中所做的addSCurves(),我们可以强加我们构造几何所需的任何方法,例如:

function addLowRollingHills(num, height) {
  num    = num    || ROAD.LENGTH.SHORT;
  height = height || ROAD.HILL.LOW;
  addRoad(num, num, num,  0,  height/2);
  addRoad(num, num, num,  0, -height);
  addRoad(num, num, num,  0,  height);
  addRoad(num, num, num,  0,  0);
  addRoad(num, num, num,  0,  height/2);
  addRoad(num, num, num,  0,  0);
}

更改更新方法


在我们创建的街机游戏中,我们不会尝试模拟现实,因此丘陵不会以任何方式影响玩家或游戏世界,这意味着该方法update()不需要更改。

希尔渲染


该方法render()也不需要更改,因为投影方程最初是为了正确投影具有非零y坐标的路段而编写的

视差滚动背景


除了将y坐标添加到所有路段外,唯一的变化将是背景图层与丘陵的垂直位移的实现(就像它们随曲线水平移动一样)。我们使用辅助函数的另一个参数来实现这一点Render.background

最简单的机制是相对于位置的通常背景位移playerY(应从当前玩家细分的世界位置y内插)。

这不是最现实的行为,因为可能值得考虑玩家当前路段的坡度,但是这种效果很简单,并且对于简单的演示来说效果很好。

结论


就是这样,现在我们可以用真实的山丘来补充假曲线了:


我们在第一部分中所做的工作,包括添加真实的3D丘陵的基础设施,我之前没有告诉过您。

在本文的最后一部分,我们将在道路边缘添加精灵,树木和广告牌。我们还将添加其他可以与之竞争的赛车,识别碰撞并修复玩家的“圈数记录”。

第4部分。就绪版本



在这一部分,我们将添加:

  • 广告牌和树木
  • 其他车
  • 碰撞识别
  • 汽车的基本AI
  • 与单圈计时器和单圈记录接口

……这将为我们提供足够的交互性,最终将我们的项目称为“游戏”。

有关代码结构的注意事项


, /, Javascript.

. () , ...

… , , , , .



在第1部分中,在游戏周期开始之前,我们上传了一个精灵表,其中包含所有的汽车,树木和广告牌。

您可以在任何图像编辑器中手动创建一个精灵表,但是最好将图像的存储和坐标的计算委托给自动化工具。在我的情况下,精灵表是由一个小的Rake任务使用Ruby Gem sprite-factory生成的

该任务从单独的图像文件生成组合的Spritesheets,并计算坐标x,y,w,h,这些坐标将存储在常量中SPRITES

var SPRITES = {
  PALM_TREE:   { x:    5, y:    5, w:  215, h:  540 },
  BILLBOARD08: { x:  230, y:    5, w:  385, h:  265 },

  // ... etc

  CAR04:       { x: 1383, y:  894, w:   80, h:   57 },
  CAR01:       { x: 1205, y: 1018, w:   80, h:   56 },
};

添加广告牌和树木


在道路的每个路段上添加一个数组,其中将包含沿道路边缘的对象精灵。

每个子图由source从集合获取的SPRITES以及水平偏移量组成offset水平偏移量已归一化,因此-1表示道路的左边缘,而+1表示右边缘,这使我们不必依赖于值roadWidth

有些精灵是有意放置的,有些则是随机的。

function addSegment() {
  segments.push({
    ...
    sprites: [],
    ...
  });
}

function addSprite(n, sprite, offset) {
  segments[n].sprites.push({ source: sprite, offset: offset });
}

function resetSprites() {

  addSprite(20,  SPRITES.BILLBOARD07, -1);
  addSprite(40,  SPRITES.BILLBOARD06, -1);
  addSprite(60,  SPRITES.BILLBOARD08, -1);
  addSprite(80,  SPRITES.BILLBOARD09, -1);
  addSprite(100, SPRITES.BILLBOARD01, -1);
  addSprite(120, SPRITES.BILLBOARD02, -1);
  addSprite(140, SPRITES.BILLBOARD03, -1);
  addSprite(160, SPRITES.BILLBOARD04, -1);
  addSprite(180, SPRITES.BILLBOARD05, -1);

  addSprite(240, SPRITES.BILLBOARD07, -1.2);
  addSprite(240, SPRITES.BILLBOARD06,  1.2);

  
  for(n = 250 ; n < 1000 ; n += 5) {
    addSprite(n, SPRITES.COLUMN, 1.1);
    addSprite(n + Util.randomInt(0,5), SPRITES.TREE1, -1 - (Math.random() * 2));
    addSprite(n + Util.randomInt(0,5), SPRITES.TREE2, -1 - (Math.random() * 2));
  }

  ...
}

注意:如果要创建真实的游戏,我们可以编写道路编辑器以可视化方式创建具有丘陵和曲线的地图,并添加沿道路布置精灵的机制...但是对于我们的任务,我们可以通过编程方式进行addSprite()

加料机


除了道路边缘的对象精灵外,我们还将添加一个将占据每个路段汽车集合,以及高速公路上所有汽车的单独集合

var cars      = [];  // array of cars on the road
var totalCars = 200; // total number of cars on the road

function addSegment() {
  segments.push({
    ...
    cars: [], // array of cars within this segment
    ...
  });
}

通过存储两个汽车数据结构,我们可以轻松地以一种方法迭代地遍历所有汽车update(),并在必要时将它们从一个区段移动到另一个区段。同时,这允许我们render()仅在可见段上执行机器。

每台机器都有一个随机的水平位移,z位置,子画面源和速度:

function resetCars() {
  cars = [];
  var n, car, segment, offset, z, sprite, speed;
  for (var n = 0 ; n < totalCars ; n++) {
    offset = Math.random() * Util.randomChoice([-0.8, 0.8]);
    z      = Math.floor(Math.random() * segments.length) * segmentLength;
    sprite = Util.randomChoice(SPRITES.CARS);
    speed  = maxSpeed/4 + Math.random() * maxSpeed/(sprite == SPRITES.SEMI ? 4 : 2);
    car = { offset: offset, z: z, sprite: sprite, speed: speed };
    segment = findSegment(car.z);
    segment.cars.push(car);
    cars.push(car);
  }
}

希尔渲染(返回)


在前面的部分中,我讨论了渲染道路段(包括曲线和山丘)的过程,但是其中有几行我没有考虑过。他们关心的是一个变量,它maxy从屏幕底部开始,但是在渲染每个段以确定我们已经渲染了屏幕的哪一部分时递减:

for(n = 0 ; n < drawDistance ; n++) {

  ...

  if ((segment.p1.camera.z <= cameraDepth) || // behind us
      (segment.p2.screen.y >= maxy))          // clip by (already rendered) segment
    continue;

  ...

  maxy = segment.p2.screen.y;
}

这将使我们可以裁剪已被渲染的山丘覆盖的部分。

艺术家的传统算法中,渲染通常发生在背面,而较近的部分则与较远的部分重叠。但是,我们不能花时间渲染最终会被覆盖的多边形,因此,如果投影坐标较小,则从前向后渲染和裁剪已被渲染的近段覆盖的遥远段变得容易maxy

渲染广告牌,树木和汽车


但是,渲染子画面时,从前到后的道路段迭代遍历将不起作用,因为它们经常相互重叠,因此必须使用艺术家的算法进行渲染。

这使我们的方法变得复杂,render()并迫使我们分两个阶段绕过路段:

  1. 从前到后进行道路渲染
  2. 后退以渲染精灵


除了部分重叠的精灵外,我们还需要处理由于山顶的地平线而“略微突出”的精灵。如果子画面足够高,则即使它所在的路段位于山坡的后部,因此也不会呈现,因此我们应该看到其上部。

我们可以通过保存的值解决这个最后的问题maxy每个段为线clip在步骤1。然后,我们可以裁剪沿线本段的精灵clip在步骤2中

,呈现逻辑的其余部分确定如何比例和位置基于系数精灵scale和坐标screen的道路段的(计算阶段1),因此在该方法的第二阶段,render()我们需要进行以下操作:

// back to front painters algorithm
for(n = (drawDistance-1) ; n > 0 ; n--) {
  segment = segments[(baseSegment.index + n) % segments.length];

  // render roadside sprites
  for(i = 0 ; i < segment.sprites.length ; i++) {
    sprite      = segment.sprites[i];
    spriteScale = segment.p1.screen.scale;
    spriteX     = segment.p1.screen.x + (spriteScale * sprite.offset * roadWidth * width/2);
    spriteY     = segment.p1.screen.y;
    Render.sprite(ctx, width, height, resolution, roadWidth, sprites, sprite.source, spriteScale, spriteX, spriteY, (sprite.offset < 0 ? -1 : 0), -1, segment.clip);
  }

  // render other cars
  for(i = 0 ; i < segment.cars.length ; i++) {
    car         = segment.cars[i];
    sprite      = car.sprite;
    spriteScale = Util.interpolate(segment.p1.screen.scale, segment.p2.screen.scale, car.percent);
    spriteX     = Util.interpolate(segment.p1.screen.x,     segment.p2.screen.x,     car.percent) + (spriteScale * car.offset * roadWidth * width/2);
    spriteY     = Util.interpolate(segment.p1.screen.y,     segment.p2.screen.y,     car.percent);
    Render.sprite(ctx, width, height, resolution, roadWidth, sprites, car.sprite, spriteScale, spriteX, spriteY, -0.5, -1, segment.clip);
  }

}

与广告牌和树木的碰撞


现在我们可以沿道路边缘添加和渲染对象精灵,我们需要更改方法update()以确定玩家在当前段中是否遇到了这些精灵中的任何

一个我们使用辅助方法Util.overlap()来实现对矩形相交的广义识别。如果检测到交叉路口,我们将停车:

if ((playerX < -1) || (playerX > 1)) {
  for(n = 0 ; n < playerSegment.sprites.length ; n++) {
    sprite  = playerSegment.sprites[n];
    spriteW = sprite.source.w * SPRITES.SCALE;
    if (Util.overlap(playerX, playerW, sprite.offset + spriteW/2 * (sprite.offset > 0 ? 1 : -1), spriteW)) {
      // stop the car
      break;
    }
  }
}

注意:如果您学习真实的代码,您会发现实际上我们不是在停车,因为那样一来,它将无法侧向移动以避开障碍物;作为一个简单的技巧,我们固定它们的位置,并允许汽车“滑动”到精灵周围。

与汽车相撞


除了沿道路边缘与小精灵碰撞外,我们还需要识别与其他汽车的碰撞,如果检测到交叉路口,我们会通过将玩家“推”回与之碰撞的机器后面来减慢玩家的速度:

for(n = 0 ; n < playerSegment.cars.length ; n++) {
  car  = playerSegment.cars[n];
  carW = car.sprite.w * SPRITES.SCALE;
  if (speed > car.speed) {
    if (Util.overlap(playerX, playerW, car.offset, carW, 0.8)) {
      // slow the car
      break;
    }
  }
}

机器更新


为了让其他汽车沿着道路行驶,我们将为他们提供最简单的AI:

  • 以恒定的速度骑
  • 超车时自动绕过玩家
  • 超车时自动绕过其他汽车

注意:我们不必担心在道路上沿着弯道转弯其他汽车,因为这些弯道不是真实的。如果我们使汽车仅沿道路段移动,它们将自动沿曲线通过。

所有这一切都发生在update()通话期间的游戏周期中,在此通话updateCars()中,我们将每辆汽车以恒定速度向前移动,如果在此帧中它们移动了足够的距离,则从一个段切换到下一个段。

function updateCars(dt, playerSegment, playerW) {
  var n, car, oldSegment, newSegment;
  for(n = 0 ; n < cars.length ; n++) {
    car         = cars[n];
    oldSegment  = findSegment(car.z);
    car.offset  = car.offset + updateCarOffset(car, oldSegment, playerSegment, playerW);
    car.z       = Util.increase(car.z, dt * car.speed, trackLength);
    car.percent = Util.percentRemaining(car.z, segmentLength); // useful for interpolation during rendering phase
    newSegment  = findSegment(car.z);
    if (oldSegment != newSegment) {
      index = oldSegment.cars.indexOf(car);
      oldSegment.cars.splice(index, 1);
      newSegment.cars.push(car);
    }
  }
}

该方法updateCarOffset()提供了“人工智能”的实现,使机器可以绕过播放器或其他机器。这是代码库中最复杂的方法之一,在实际游戏中,它应该更加复杂,以使机器看上去比简单的演示更为真实。

在我们的项目中,我们使用幼稚的AI蛮力,迫使每台机器:

  • 期待20个细分市场
  • 如果她发现前方有较慢的汽车横穿自己的小路,则绕过她
  • 从道路左侧的障碍物向右转
  • 在道路的右侧向左拐弯
  • 转弯足以避免在剩余距离内前方的障碍物

我们还可以用那些玩家看不见的汽车作弊,从而使它们不能相互绕过并通过。他们应该只在玩家的视野范围内才显得“聪明”。

function updateCarOffset(car, carSegment, playerSegment, playerW) {

  var i, j, dir, segment, otherCar, otherCarW, lookahead = 20, carW = car.sprite.w * SPRITES.SCALE;

  // optimization, dont bother steering around other cars when 'out of sight' of the player
  if ((carSegment.index - playerSegment.index) > drawDistance)
    return 0;

  for(i = 1 ; i < lookahead ; i++) {
    segment = segments[(carSegment.index+i)%segments.length];

    if ((segment === playerSegment) && (car.speed > speed) && (Util.overlap(playerX, playerW, car.offset, carW, 1.2))) {
      if (playerX > 0.5)
        dir = -1;
      else if (playerX < -0.5)
        dir = 1;
      else
        dir = (car.offset > playerX) ? 1 : -1;
      return dir * 1/i * (car.speed-speed)/maxSpeed; // the closer the cars (smaller i) and the greater the speed ratio, the larger the offset
    }

    for(j = 0 ; j < segment.cars.length ; j++) {
      otherCar  = segment.cars[j];
      otherCarW = otherCar.sprite.w * SPRITES.SCALE;
      if ((car.speed > otherCar.speed) && Util.overlap(car.offset, carW, otherCar.offset, otherCarW, 1.2)) {
        if (otherCar.offset > 0.5)
          dir = -1;
        else if (otherCar.offset < -0.5)
          dir = 1;
        else
          dir = (car.offset > otherCar.offset) ? 1 : -1;
        return dir * 1/i * (car.speed-otherCar.speed)/maxSpeed;
      }
    }
  }
}

在大多数情况下,该算法效果很好,但是在前面有大量汽车的情况下,我们可以注意到汽车在从左向右和向后移动,试图挤入其他两台机器之间的缝隙。有许多方法可以提高AI的可靠性,例如,如果汽车发现没有足够的空间来避开障碍物,则可以让汽车减速。

接口


最后,我们将创建一个基本的HTML界面:

<div id =“ hud”>
  <span id =“ speed” class =“ hud”> <span id =“ speed_value” class =“ value”> 0 </ span> mph </ span>
  <span id =“ current_lap_time” class =“ hud”>时间:<span id =“ current_lap_time_value” class =“ value”> 0.0 </ span> </ span> 
  <span id =“ last_lap_time” class =“ hud”>最后一圈:<span id =“ last_lap_time_value” class =“ value”> 0.0 </ span> </ span>
  <span id =“ fast_lap_time” class =“ hud”>最快圈:<span id =“ fast_lap_time_value” class =“ value”> 0.0 </ span> </ span>
</ div>

...并为其添加CSS样式

#hud                   { position: absolute; z-index: 1; width: 640px; padding: 5px 0; font-family: Verdana, Geneva, sans-serif; font-size: 0.8em; background-color: rgba(255,0,0,0.4); color: black; border-bottom: 2px solid black; box-sizing: border-box; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; }
#hud .hud              { background-color: rgba(255,255,255,0.6); padding: 5px; border: 1px solid black; margin: 0 5px; transition-property: background-color; transition-duration: 2s; -webkit-transition-property: background-color; -webkit-transition-duration: 2s; }
#hud #speed            { float: right; }
#hud #current_lap_time { float: left;  }
#hud #last_lap_time    { float: left; display: none;  }
#hud #fast_lap_time    { display: block; width: 12em;  margin: 0 auto; text-align: center; transition-property: background-color; transition-duration: 2s; -webkit-transition-property: background-color; -webkit-transition-duration: 2s; }
#hud .value            { color: black; font-weight: bold; }
#hud .fastest          { background-color: rgba(255,215,0,0.5); }


...,我们将在游戏周期内执行其update():

if (position > playerZ) {
  if (currentLapTime && (startPosition < playerZ)) {
    lastLapTime    = currentLapTime;
    currentLapTime = 0;
    if (lastLapTime <= Util.toFloat(Dom.storage.fast_lap_time)) {
      Dom.storage.fast_lap_time = lastLapTime;
      updateHud('fast_lap_time', formatTime(lastLapTime));
      Dom.addClassName('fast_lap_time', 'fastest');
      Dom.addClassName('last_lap_time', 'fastest');
    }
    else {
      Dom.removeClassName('fast_lap_time', 'fastest');
      Dom.removeClassName('last_lap_time', 'fastest');
    }
    updateHud('last_lap_time', formatTime(lastLapTime));
    Dom.show('last_lap_time');
  }
  else {
    currentLapTime += dt;
  }
}

updateHud('speed',            5 * Math.round(speed/500));
updateHud('current_lap_time', formatTime(currentLapTime));

辅助方法updateHud()允许我们仅在值更改时更新DOM元素,因为这样的更新可能是一个缓慢的过程,并且如果值本身不更改,我们不应该以60fps的速度执行它。

function updateHud(key, value) { // accessing DOM can be slow, so only do it if value has changed
  if (hud[key].value !== value) {
    hud[key].value = value;
    Dom.set(hud[key].dom, value);
  }
}

结论



最后一部分很长,但是我们仍然完成了,完成的版本到达了可以称为游戏的阶段。她距离完成的比赛还很遥远,但这仍然是一场比赛。

尽管如此简单,但我们确实设法创建了一个游戏,这真是令人惊讶。我不打算将这个项目带入一个完整的状态。它仅应视为对伪三维赛车游戏主题介绍

该代码由github发布,您可以尝试将其转变为更高级的赛车游戏。您也可以尝试:

  • 为汽车增加音效
  • 改善音乐同步
  • 全屏实施
  • ( , , , ..)
  • (, ..)
  • ,
  • , -
  • ,
  • ( , ..)
  • drawDistance
  • x,y
  • ( , )
  • 叉子和公路连接
  • 昼夜的变化
  • 天气状况
  • 隧道,桥梁,云层,墙壁,建筑物
  • 城市,沙漠,海洋
  • 将西雅图和太空针塔添加到背景中
  • “恶棍”-增加竞争对手与之竞争
  • 游戏模式-最快圈速,一对一比赛(捡硬币?向小人射击?)
  • 大量的游戏自定义选项
  • 等等
  • ...

至此就完成了。另一个“周末项目”花费的时间比预期的要长得多,但最终结果还是不错的。

参考文献



可播放演示的链接:


All Articles