Часть 3. Холмы



В предыдущей части мы создали простую псевдотрёхмерную гоночную игру, реализовав в ней прямые дороги и кривые.

На этот раз мы займёмся холмами; к счастью, это будет намного проще, чем создание искривлённых дорог.

В первой части мы использовали закон подобных треугольников для создания трёхмерной перспективной проекции:


… что привело нас к получению уравнений проецирования координаты 3d-мира в координату 2d-экрана.


… но так как тогда мы работали только с прямыми дорогами, мировым координатам нужна была только компонента z, потому что и x, и y были равны нулю.

Это хорошо нам подходит, потому что для добавления холмов нам достаточно дать сегментам дороги соответствующую ненулевую координату 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


В создаваемой нами аркадной игре мы не будем стремиться симулировать реальность, поэтому холмы никак не влияют на игрока или игровой мир, а значит, в методе update() изменения не требуются.

Рендеринг холмов


В методе render() тоже не требуются никакие изменения, потому что уравнения проецирования изначально записаны так, чтобы правильно проецировать сегменты дороги с ненулевыми координатами y.

Фон с параллаксным скроллингом


Кроме добавления всем сегментам дороги координат y, единственным изменением будет реализация вертикального смещения слоёв фона вместе с холмами (так же, как они сдвигаются по горизонтали вместе с кривыми). Мы реализуем это при помощи ещё одного аргумента вспомогательной функции Render.background.

Простейшим механизмом будет обычное смещение фона относительно позиции playerY (которая должна интерполироваться из мировых позиций y текущего сегмента игрока).

Это не самое реалистичное поведение, потому что, вероятно, стоит учесть наклон текущего сегмента дороги игрока, но этот эффект прост и для простого демо работает достаточно хорошо.

Заключение


Вот и всё, теперь мы можем дополнить фальшивые кривые реальными холмами:


Проделанная нами в первой части работа в том числе включала в себя инфраструктуру для добавления настоящих проецируемых 3d-холмов, просто раньше я вам об этом не говорил.

В последней части статьи мы добавим спрайты, а также деревья и билборды по краям дороги. Ещё мы добавим другие машины, против которых можно будет соревноваться, распознавание коллизий и фиксацию «рекорда круга» игрока.

Часть 4. Готовая версия



В этой части мы добавим:

  • Билборды и деревья
  • Другие машины
  • Распознавание коллизий
  • Рудиментарный ИИ автомобилей
  • Интерфейс с таймером круга и рекордом круга

… и это обеспечит нам достаточный уровень интерактивности, чтобы наконец назвать наш проект «игрой».

Примечание о структуре кода


Как я упоминал в предыдущей статье, этот код является техническим демо с глобальными переменными и малым количеством классов/структур, поэтому не может служить примером качественного кода на Javascript.

В этом последнем разделе мы добавим намного больше кода для работы со спрайтами и обработки других машин. При этом мы пересечём (субъективную) границу, за которой простой код становится более сложным и может выиграть от лучшей структурированности...

… но он не получит этой структуры, потому что это всего лишь техническое демо, так что простите меня за немного хаотичные примеры кода из статьи, и поверьте, что в реальном проекте вам придётся активно его подчищать.

Спрайты



В части 1 перед началом игрового цикла мы загрузили спрайтшит, содержащий все машины, деревья и билборды.

Вы можете вручную создать спрайтшит в любом редакторе изображений, но хранение изображений и вычисление координат лучше доверить автоматизированному инструменту. В моём случае спрайтшит был сгенерирован небольшим таском Rake при помощи Ruby Gem sprite-factory.

Этот таск генерирует объединённые спрайтшиты из отдельных файлов изображений, а также вычисляет координаты 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;
    }
  }
}

Обновление машин


Чтобы другие машины двигались по дороге, мы дадим им простейший ИИ:

  • ехать с постоянной скоростью
  • автоматически объезжать игрока при обгоне
  • автоматически объезжать другие машины при обгоне

Примечание: нам не нужно волноваться о поворотах других машин по кривой на дороге, потому что кривые ненастоящие. Если мы сделаем так, чтобы машины просто двигались по сегментам дороги, то они автоматически проедут по кривым.

Всё это происходит во время игрового цикла 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() обеспечивает реализацию «искусственного интеллекта», позволяющего машине объезжать игрока или другие машины. Это один из самых сложных методов в кодовой базе, и в реальной игре он должен быть намного более сложным, чтобы машины казались гораздо реалистичнее, чем в простом демо.

В нашем проекте мы используем наивный ИИ с грубым перебором, заставляющий каждую машину:

  • смотреть вперёд на 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;
      }
    }
  }
}

В большинстве случаев этот алгоритм работает достаточно хорошо, но при большом столпотворении машин впереди мы можем заметить, что машины движутся слева направо и обратно, пытаясь протиснуться в зазор между двумя другими машинами. Существует множество способов, позволяющих повысить надёжность ИИ, например, можно позволить машинам замедляться, если они видят, что для объезда препятствий недостаточно места.

Интерфейс


Наконец, мы создадим рудиментарный 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">Time: <span id="current_lap_time_value" class="value">0.0</span></span> 
  <span id="last_lap_time"    class="hud">Last Lap: <span id="last_lap_time_value" class="value">0.0</span></span>
  <span id="fast_lap_time"    class="hud">Fastest Lap: <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
  • устранить субпиксельные артефакты алиасинга на кривых
  • более продуманный туман для сокрытия спрайтов (синий на фоне неба, скрывающий спрайты)
  • несколько уровней
  • различные карты
  • карта круга с индикатором текущего положения
  • развилки и соединения дорог
  • смена дня и ночи
  • погодные явления
  • туннели, мосты, облака, стены, здания
  • город, пустыня, океан
  • добавить в фоны город Сиэтл и Спейс-Нидл
  • «злодеи» — добавить конкурентов, с которыми можно соревноваться
  • игровые режимы — самый быстрый круг, гонка один на один (собирание монет?, стрельба по злодеям?)
  • куча возможностей для настройки геймплея
  • и т.д.
  • ...

Итак, мы закончили. Ещё один «проект на выходные», который занял намного больше, чем ожидалось, но в конечном итоге результат оказался достаточно хорошим.

Ссылки



Ссылки на играбельные демо: