Статьи

Создание игры с помощью JavaScript: заставляем вещи двигаться

Это продолжение предыдущего поста ,

Установка сцены

Игра, которую мы строим, будет иметь волны вражеских кораблей, чтобы атаковать юнитов игрока. Давайте начнем с создания простого врага и нескольких фиктивных целей для атаки. Я собираюсь сделать графику очень простой на данный момент. Точно так же мы собираемся сосредоточиться на поведении врага и пока не беспокоиться о взаимодействии игроков.

Вот демонстрация того, что мы сделаем. Нажмите на стартовый экран, чтобы перейти в игру. Маленькие желтые прямоугольники — наши вражеские корабли. Каждый проецирует свою собственную цель как маленький красный круг. Как только он касается своей цели, он проецирует новую, а затем летит к ней.

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

Если delta0, нам не нужно менять 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 .