Это продолжение предыдущего поста ,
Установка сцены
Игра, которую мы строим, будет иметь волны вражеских кораблей, чтобы атаковать юнитов игрока. Давайте начнем с создания простого врага и нескольких фиктивных целей для атаки. Я собираюсь сделать графику очень простой на данный момент. Точно так же мы собираемся сосредоточиться на поведении врага и пока не беспокоиться о взаимодействии игроков.
Вот демонстрация того, что мы сделаем. Нажмите на стартовый экран, чтобы перейти в игру. Маленькие желтые прямоугольники — наши вражеские корабли. Каждый проецирует свою собственную цель как маленький красный круг. Как только он касается своей цели, он проецирует новую, а затем летит к ней.
Давайте начнем сверху вниз. Наши вражеские отряды будут «жить» на главном экране игры. (По крайней мере, на данный момент.) Этот экран должен отображать тот же интерфейс, который мы использовали для стартового экрана, который мы сделали в предыдущем посте. Мы также добавим start
метод, который будем вызывать только один раз, чтобы инициализировать вещи.
Реализация
Вот реализация:
var mainGameScreen = (function () { // the set of entities we're updating and rendering var entities = []; // how many enemy ships do we want to start with var numOfEnemyShips = 12; // intitalize the screen, expected to be called // once when transitioning to the screen function start() { for (var i = 0; i <= numOfEnemyShips; i++) { // the numbers passed into `makeEnemyShip` could be anything // they don't need to be derived from `i` entities.push(makeEnemyShip(i * 10, i)); } } // drawing the screen means drawing each of its constituents function draw(ctx) { // first, clean the canvas ctx.fillStyle = 'black'; ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); // delegate the drawing of each entity to itself // note the difference in the way the for loop is set up var entityIndex = entities.length - 1; for (; entityIndex != 0; entityIndex--) { entities[entityIndex].draw(ctx); } } // much like draw, we update each of the screen's constituents function update(elapsed) { var entityIndex = entities.length - 1; for (; entityIndex != 0; entityIndex--) { entities[entityIndex].update(elapsed); } } // this is the object that will be the main screen return { draw: draw, update: update, start: start }; }());
объяснение
entities
Массив будет содержать список врагов мы отслеживание. Я использовал название «сущность», потому что это общий термин в разработке игр. В общем, это означает что-то, что имеет поведение и обращается к экрану. Таким образом, можно ожидать, что сущности будут иметь update
и draw
методы. Это не жесткое и быстрое определение. Вы обнаружите, что специфика определения может варьироваться в зависимости от движков, сред и разработчиков.
В нашей start
функции мы заполняем entities
, вызывая нашу (пока еще не определенную) makeEnemyShip
функцию. Я передаю два числа, которые makeEnemyShip
будут использоваться, чтобы установить координаты x и y корабля. Я мог бы использовать случайные числа или даже жестко закодированные значения, однако вывод из элементов управления цикла облегчает кластеризацию всех кораблей в верхнем левом углу экрана.
Функции draw
и update
для экрана очень похожи. Они оба перебирают entities
и вызывают соответствующую функцию для каждого объекта. Они также передают необходимый контекст. Ведь draw
это контекст 2D-рисования холста, и update
это время, прошедшее с последнего кадра.
Обратите внимание, что цикл структурирован иначе, чем цикл в start
. Это оптимизация производительности; хотя это имеет мало последствий с таким маленьким массивом. В некоторых браузерах вызов length
немного дороже. (Особенно в тех случаях, когда массив не является массивом, а похож на массив .) Это складывается, когда вы делаете вызов один раз за итерацию цикла. Мы выводим его из цикла, чтобы вызывать его только один раз. Проверьте этот тест . Оптимизация производительности сложна и меняется каждый раз, когда выпускаются новые браузеры. Это легко запутать, и я рекомендую часто профилировать ваш код, чтобы искать горячие точки, а не просто догадываться об оптимизации. Я надеюсь поговорить о них позже, но если вы хотите больше, посмотрите книгуВысокопроизводительный JavaScript от Николаса С. Закаса .
Первоначально я написал свои циклы, используя более новый Array.forEach для итерации entities
. Однако это оказалось значительно медленнее, чем for
петля.
Метод экрана draw
также сбрасывает холст в начале каждой итерации. Если бы мы этого не сделали, то все, что мы нарисовали на предыдущих кадрах, все равно будет присутствовать. Для начального экрана я использовал, clearRect
однако, здесь я использовал fillRect
сплошной цвет.
Вот функция, которая будет производить простого врага. Он придерживается той же структуры, которую мы использовали, update
чтобы управлять поведением и draw
фактически рисовать его на экране.
Некоторые плохие парни
Наши вражеские корабли немного сложнее, чем экран, на котором они живут. Визуально они, кажется, имеют два компонента. Маленький желтый прямоугольник, который движется по экрану, и призрачная цель, которую они проецируют как маленький красный круг. В финальной игре они будут нацелены на одно из подразделений игрока. Однако логика очень похожа. Фактически, это может быть полезно при отладке того, как каждый вражеский корабль отрисовывает что-то поверх своей реальной цели.
Реализация
// alias (and pre-compute) the angle of // a full circle (360°, but in radians) var fullCircle = Math.PI * 2; // invoke this function to create an emeny ship entity // to add to the main game screen function makeEnemyShip(x, y) { // position is set based upon the values // provided to the function var position = { x: x, y: y }; // the diretion the ship is facing var orientation = 0; // how quickly can the ship turn? var turnSpeed = fullCircle / 50; // how quickly can the ship move? var speed = 2; // the phantom target the ship // is pursing var target = findNewTarget(); // the function invoked from the screen's update function update(elapsed) { // determine how close we are to our target var y = target.y - position.y; var x = target.x - position.x; var d2 = Math.pow(x, 2) + Math.pow(y, 2); if (d2 < 16) { // we've essentially "touched" it, // so create a new target target = findNewTarget(); } else { // what's the different between our orientation // and the angle we want to face in order to // move directly at our target var angle = Math.atan2(y, x); var delta = angle - orientation; var delta_abs = Math.abs(delta); // if the different is more than 180°, convert // the angle a corresponding negative value if (delta_abs > Math.PI) { delta = delta_abs - fullCircle; } // if the angle is already correct, // don't bother adjusting if (delta !== 0) { // do we turn left or right? var direction = delta / delta_abs; // update our orientation orientation += (direction * Math.min(turnSpeed, delta_abs)); } // constrain orientation to reasonable bounds orientation %= fullCircle; // use orientation and speed to update our position position.x += Math.cos(orientation) * speed; position.y += Math.sin(orientation) * speed; } } // the function invoked from the screen's draw function draw(ctx) { // save the context's state before we translate // and rotate ctx.save(); // translate to the entity's position ctx.translate(position.x, position.y); // rotate the canvas according to the // entity's orientation ctx.rotate(orientation); // now we begin the actual drawing ctx.fillStyle = 'yellow'; // using negative number to center // around the translated origin ctx.fillRect(-3, -1, 6, 2); // restore the canvas since we're // done drawing the entity ctx.restore(); // now we draw the phantom target // though we'll do so without translating // since it's so simle to draw ctx.beginPath(); ctx.fillStyle = 'rgba(255,0,0,0.5)'; ctx.arc(target.x, target.y, 2, 0, Math.PI * 2, true); ctx.fill(); } // create a random x,y within the bounds of the canvas // note, we've hard coded the bounds function findNewTarget() { var target = { x: Math.round(Math.random() * 600), y: Math.round(Math.random() * 300) }; return target; } // return an instance of the enemy, // it's state is captured in the closure // of the functions return { draw: draw, update: update } }
объяснение
Каждый вражеский корабль будет отвечать за отслеживание своего состояния. В этом коде состояние фиксируется в замыкании. В следующем коде мы будем отслеживать трек более традиционным способом. (Я еще не проводил тесты, но думаю, что использование замыкания может повлиять на производительность.)
Все эти переменные представляют состояние вражеского корабля.
var position = { x: 0, y: 0 }; var orientation = 0; var turnSpeed = fullCircle / 50; var speed = 2; var target = findNewTarget();
position
это место на экране, где мы будем отображать наш корабль.
Технически, это позиция в «мировом пространстве». Пространство мира — это логическое пространство, в котором «живут» существа в вашей игре. Это отличается от «экранного пространства», которое соответствует фактическим пикселям на экранах. Вы можете думать об этом так: в вашей игре у вас может быть круг с радиусом 10 и расположенный в (100 100). Однако то, где вы рисуете его на экране, будет зависеть от того, откуда игрок его просматривает. Если игрок увеличит масштаб, круг увеличится, но это не изменит логическую позицию или радиус круга. Мы используем термин «проекция», чтобы описать это. Мы проецируем из мирового пространства в пространство экрана, основываясь на том, как игрок смотрит на игровой мир. Самый простой проект, конечно, всего 1: 1. Это означает, что нет никакой разницы между мировым пространством и пространством экрана. Это то, что будет придерживаться на данный момент.
orientation
это направление, в котором сейчас находится корабль. Наш корабль всегда будет двигаться в направлении своей ориентации. Это ограничение вызывает движение корабля по плавным дугам, а не резкое изменение его курса.
turnSpeed
и speed
представлять, как быстро корабль может поворачивать и как быстро он может двигаться соответственно. Мы не будем изменять эти значения после их установки, что означает, что корабль будет вращаться и двигаться с постоянной скоростью. Эти значения представляют скорости изменения для orientation
и position
. Обратите внимание, что мы определили turnSpeed
в терминах fullCircle
. Это псевдоним для Math.PI * 2
; мы имеем дело с радианами, а не с градусами.
target
это значение с формой { x: Number, y: Number }
. Корабль всегда будет пытаться приблизиться к этому значению, корректируя его orientation
.
Обновить
update
Функция реальное мясо вражеского корабля. Сначала мы проверяем, близки ли мы к нашей цели. Если мы достаточно близки, мы считаем нашу цель достигнутой и ставим новую цель. В противном случае мы меняем нашу orientation
так, чтобы мы летели к нашей текущей цели.
var y = target.y - position.y; var x = target.x - position.x; var d2 = Math.pow(x, 2) + Math.pow(y, 2);
Здесь x
и y
есть действительно расстояние между target
и position
вдоль соответствующих осей. Мы хотим знать эти значения, чтобы рассчитать расстояние между ними. В общем, вы используете пифагорейскую теорию для вычисления расстояния. Чтобы глубже погрузиться в математику, посмотрите формула расстояния в Академии хана. Для определения фактического реального расстояния используется квадратный корень, а вычисление квадратного корня — дорогостоящая операция, которую лучше избегать, когда вы можете.
Мы можем обойти необходимость, работая с расстоянием² (отсюда и имя переменной d2
). Мы сравниваем это с жестко закодированным значением 16 (это 4²). Другими словами, если расстояние между кораблем и его целью составляет менее 4 единиц, мы находим новую цель.
if (d2 < 16) { target = findNewTarget(); }
Как только мы установили, какой должна быть цель корабля, мы хотим, чтобы корабль двигался к цели. Как я только что упомянул, я решил, чтобы корабль двигался с постоянной скоростью. Это означает, что он не замедляется и не ускоряется. Единственное, что он может сделать, это изменить направление его движения ( orientation
). Такого рода ограничения определяют личность и характер вашей игры. Имейте в виду, вам не нужно имитировать физику, чтобы получить удовольствие от игры. Вместо этого вам нужно установить поведение для ваших игровых сущностей, которое будет просто забавным. К счастью, забавное поведение часто легче реализовать, чем физику. Я рекомендую взглянуть на демо и тонкой настройки turnSpeed
и speed
значения , чтобы получить небольшой вкус , как поведение может повлиять на характер игры.
Чтобы изменить ориентацию корабля, нам нужно сначала определить, куда должен быть направлен корабль . Значения x
и y
только что вычисленные значения можно интерпретировать как вектор . Это означает, что оно представляет направление и расстояние (величину) от корабля до текущей цели. Чтобы извлечь фактический угол (в радианах) мы используем Math.atan2(x,y)
.
var angle = Math.atan2(y, x); var delta = angle - orientation;
Так что теперь у нас есть направление корабль хочет к лицу, angle
и направление , в котором она находится , обращенная, orientation
. Мы рассчитываем разницу между ними и храним ее как delta
.
Основная идея заключается в том, что добавить значение , turnSpeed
чтобы orientation
один раз каждый вызов udpate
до тех пор , delta
пока 0 ( это означает , что корабль летит прямо в цель). Однако некоторые значения delta
приведут к тому, что корабль «повернет не в ту сторону». Например, представьте, что корабль стоит перед верхней частью экрана, и мы определили это как orientation === 0
. Теперь представьте, что цель находится прямо справа от нее. Значение angle
будет π / 2 (или 90 °). Добавление turnSpeed
к orientation
каждому кадру увеличивает значение от 0 до π / 2. Однако, если цель находится прямо слева, то значение angle
будет 3π / 2 (или 270 °). Просто увеличиваяorientation
заставит корабль повернуть направо и продолжать поворачивать направо. Это не интуитивное поведение; мы ожидаем, что корабль повернет короткое расстояние. Для этого мы delta
вычитаем любое значение, превышающее π (180 °) fullCircle
. Это нормализует значение от delta
-π до π (или от -180 ° до 180 °).
var delta_abs = Math.abs(delta); if (delta_abs > Math.PI) { delta = delta_abs - fullCircle; }
Мы берем абсолютное значение, delta
потому что в противном случае мы должны были бы также проверить delta < -Math.PI
. Кроме того, мы будем использовать delta_abs
снова.
Если delta
0, нам не нужно менять orientation
. Когда это отличается, мы должны изменить значение orientation
.
if (delta !== 0) { var direction = delta / delta_abs; orientation += (direction * Math.min(turnSpeed, delta_abs)); orientation %= fullCircle; }
Во-первых, мы решаем, как сильно его изменить, используя Math.min(turnSpeed, delta_abs)
. Мы могли бы просто использовать turnSpeed
. Однако, если бы мы это сделали, вполне вероятно, delta
что никогда не будет 0 и (в зависимости от размера turnSpeed
) это может привести к дрожанию движения. Во-вторых, мы хотим решить, в каком направлении повернуть: положительные значения справа и отрицательные значения слева. Мы умножаем сумму, direction
чтобы изменить знак, потому что direction
будет только 1 или -1. Наконец, мы по модулю, orientation
чтобы убедиться, что он находится в диапазоне от -2π до 2π. В противном случае расчет delta
будет выключен.
Наш последний шаг в update
том, чтобы изменить реальную позицию, используя последние orientation
и speed
.
position.x += Math.cos(orientation) * speed; position.y += Math.sin(orientation) * speed;
Некоторая базовая тригонометрия довольно фундаментальна для большинства игр. Если вы математически потерялись в этот момент, я рекомендую просмотреть в Хан Академии .
Вот геометрическая интерпретация кода. Мы хотим, чтобы корабль отодвинулся speed
в направлении orientation
. Для этого нам нужно спроецировать этот вектор (расстояние и направление) на оси x и y. Поскольку расстояние — это длина гипотенузы прямоугольного треугольника, косинус дает нам часть x, а синус — часть y. Затем мы можем добавить эти значения в текущую позицию.
Привлечь
Отрисовать корабль на экране немного проще. Вот поток логики:
- Сохраните состояние контекста рисования.
- Преобразуйте контекст, чтобы было проще нарисовать наш корабль.
- Нарисуй корабль.
- Восстановите контекст обратно в исходное состояние.
-
Нарисуй цель
function draw(ctx) { ctx.save(); ctx.translate(position.x, position.y); ctx.rotate(orientation); ctx.fillStyle = 'yellow'; ctx.fillRect(-3, -1, 6, 2); ctx.restore(); ctx.beginPath(); ctx.fillStyle = 'rgba(255,0,0,0.5)'; ctx.arc(target.x, target.y, 2, 0, Math.PI * 2, true); ctx.fill(); }
Напомним, что ctx
это контекст рисования для холста. Контекст предоставляет полезный API, который позволяет нам перемещать его, прежде чем мы начнем его использовать. Это похоже на наличие листа бумаги, который вы могли бы сдвигать и поворачивать, чтобы было легче нарисовать что-то сложное. Это цель translate
и rotate
методы.
Во-первых, мы используем «сохранить», чтобы установить контрольную точку для контекста рисования, которую мы можем легко вернуться к использованию «восстановления». Вызовы translate
и rotate
изменение состояния контекста рисования. Это измененное состояние очень специфично для чертежа нашего вражеского корабля. Если бы мы не переводили и не вращали холст, нам пришлось бы сделать гораздо больше работы, чтобы выяснить, где нарисовать четыре угла прямоугольника.
Я решил, что хочу, чтобы мой корабль был длиной 6 пикселей и шириной 2 пикселя. Так как я хочу, чтобы середина моего корабля была точкой, где он вращается, я смещаюсь на половину длины и половину ширины. Следовательно, значения передаются в ctx.fillRect(-3, -1, 6, 2)
. Это указывает новый источник (0,0) в середине прямоугольника, и это заставляет нас призывать rotate
вести себя интуитивно. Если бы мы использовали ctx.fillRect(0, 0, 6, 2)
вместо этого, то казалось бы, что корабль вращается вокруг своего верхнего левого угла. Мы будем использовать эти же методы, как только перейдем к использованию спрайтов .
После того, как мы восстанавливаем состояние контекста, мы рисуем цель. Мы не беспокоимся, rotate
потому что вращать простой круг бессмысленно. Точно так же мы не беспокоимся, translate
поскольку логика рисования очень проста.
Холст сам по себе является широкой темой. Я рекомендую взглянуть на учебники в MDN . Удобный справочник по самим API можно найти в MSDN .