Камера для игры на JavaScript

Пришло в голову несколько идей для открытой мультиплеерной игры. В качестве языка я использую JavaScript. В связи с тем что я его плохо понимаю возникает проблема.

Существует условный игровой мир. Пока - холст 600x400 пикселей. Игроки на данном этапе представлены кружочками. Они могут перемещаться по холсту и видеть перемещения друг друга благодаря серверу на python реализованном с использованием websocket.

Проблема

Нужно реализовать камеру которая будет следить за игроком пока тот перемещается по большему игровому миру.

Попытки решения проблемы

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

Чего я хочу?

Было-бы славно, если кто-то поможет мне ответом как реализовать такую камеру чтоб она комфортно работала при больших размерах мира. Как пример могу привести diep.io, ponytown, surviv.io и подобные.

Материалы

так как я не понимаю что конкретно следует предоставлять, предоставлю всю клиентскую часть.

файл JavaScript с названием script.js

файлы HTML&СSS с названиями index.html, index_style.css соответственно


Ответы (1 шт):

Автор решения: Leonid

Сначала организуем управление движением героя (черного кружочка) по карте. Например, мы можем задавать курсором направление движения на карте и функция moveDir запустит с интервалом inertia_frame в течение inertia_t миллисекунд изменение в позиции героя (pos.x, pos.y) на расстояние inertia_l по направлению клика. Шагом изменений может быть и 10 миллисекунд.

Отрисовка же героя (в новом или прежнем положении) отрабатывается с оптимальной частотой смены кадров (animationFrame). Если изменений нет, то графический препроцессор оставит и кадр без изменений.

Исходя из новой позиции героя, мы перемещаем начало координат с помощью translate(pos.x,pos.y), рисуем героя в "нолевой" позиции и возвращаем настройки матрицы трансформаций - ctx.resetTransform() (либо ctx.save() отрисовка ctx.restore()).

const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const w = canvas.width = 600;
const h = canvas.height = 400;

const r = 10;

const inertia_t = 600;
const inertia_l = 50;
const inertia_frame = 10;

let pos = {
  x: w / 2,
  y: h / 2
};

canvas.addEventListener('click', moveDir);

function drawMe() {
  ctx.clearRect(0, 0, w, h);
  ctx.translate(pos.x, pos.y);

  ctx.fill(new Path2D(`M ${-r} 0 A ${r} ${r} 90 1 1 ${r} 0 ${r} ${r} 90 1 1 ${-r} 0 z`));
  ctx.resetTransform();

  requestAnimationFrame(drawMe);
}

requestAnimationFrame(drawMe);


function moveDir(e) {
  const dx = e.offsetX - pos.x;
  const dy = e.offsetY - pos.y;

  const k = inertia_l / Math.sqrt(dx * dx + dy * dy);
  let step_num = inertia_t / inertia_frame;
  const step_x = dx * k / step_num;
  const step_y = dy * k / step_num;

  const moveId = setInterval(() => {
    pos.x += step_x;
    pos.y += step_y;
    step_num--;

    if (!step_num) {
      clearInterval(moveId);
    }
  }, inertia_frame);

}
<canvas id="game"></canvas>

Теперь к решению вопроса. На отдельном холсте отрисовываем мир 10000х10000. Для наглядности разбиваем его на клетки 1/100х1/100 с градиентом изменения цвета заливки каждой условной клетки.

Героя помещаем в центр этого большого мира (можно указать любые координаты). Но теперь при отрисовке героя сначала помещаем кусок мира с помощью ctx.drawImage(map, pos.x - w / 2, pos.y - h / 2, w, h, 0, 0, w, h). То есть в качестве изображения берем большой холст карты, начало фрагмента устанавливаем относительно позиции героя, а размер фрагмента совпадает с размером игрового поля. На холсте игрового поля же рисуем этот фрагмент от 0,0 на весь размер игрового поля. Более рационально должно быть трансформация холста мира в BitMap - битмэп точно также передается первым аргументом в drawImage().

Герой же всегда рисуется в центре поля. Функция перемещения та же, только направление движение считается от центра игрового поля. Поэтому героя лучше для продакшена перемещать на отдельный холст, лежащий поверх прочих (с помощью банального css z-index).

Нижележащий холст может быть отведен для других игроков, этот холст должен быть размером в карту (10000х10000), на который помещаются другие игроки, координаты которых поступают через web-socket. Управление этим слоем лучше передать Web Worker, который и будет держать соединение с сервером, а игроки помещаться на связанный OffscreenCanvas в этом же воркере.

И третий, самый нижний холст для фрагмента мира - игрового поля. Дополнительный слой может быть выделен для предметов, строений и так далее. Все слои, кроме верхнего с героем отрисовываются точно так же как и мир (map).

const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const w = canvas.width = 600; // Размеры игрового поля
const h = canvas.height = 400;

const map_w = 10_000; // Размер карты (мира)
const map_h = 10_000;
const map = drawMap(map_w, map_h);

const r = 10;

const inertia_t = 600;
const inertia_l = 50;
const inertia_frame = 10;

let pos = {
  x: map_w / 2,
  y: map_h / 2
};

canvas.addEventListener('click', moveDir);

function drawMe() {
  ctx.clearRect(0, 0, w, h);
  ctx.drawImage(map, pos.x - w / 2, pos.y - h / 2, w, h, 0, 0, w, h);

  ctx.fill(new Path2D(`M ${w/2-r} ${h/2} A ${r} ${r} 90 1 1 ${w/2+r} ${h/2} ${r} ${r} 90 1 1 ${w/2-r} ${h/2} z`));
  requestAnimationFrame(drawMe);
}

requestAnimationFrame(drawMe);


function moveDir(e) {
  const dx = e.offsetX - w / 2;
  const dy = e.offsetY - h / 2;

  const k = inertia_l / Math.sqrt(dx * dx + dy * dy);
  let step_num = inertia_t / inertia_frame;
  const step_x = dx * k / step_num;
  const step_y = dy * k / step_num;


  const moveId = setInterval(() => {
    pos.x += step_x;
    pos.y += step_y;
    step_num--;

    if (!step_num) {
      clearInterval(moveId);
    }
  }, inertia_frame);

}


function drawMap(width, height) {
  const map = document.createElement('canvas');
  const m_ctx = map.getContext('2d');
  const m_w = map.width = width;
  const m_h = map.height = height;



  const cell = {
    w: m_w / 100,
    h: m_h / 100
  };

  m_ctx.font = `${cell.w/8}px sans-serif`;
  m_ctx.textAlign = 'center';
  m_ctx.textBaseline = 'middle';
  m_ctx.strokeStyle = 'grey';

  for (let raw = 0; raw < 100; raw++) {
    for (let col = 0; col < 100; col++) {
      m_ctx.fillStyle = `rgb(${255-raw},${255-col},${255-raw-col})`;
      m_ctx.fillRect(col * cell.w + 1, raw * cell.h + 1, cell.w - 2, cell.h - 2);
      m_ctx.strokeText(`raw ${raw}, col ${col}`, col * cell.w + cell.w / 2, raw * cell.h + cell.h / 2);

    }
  }
  return map;
}
<canvas id="game"></canvas>

→ Ссылка