Плавное 2D-движение с нуля (javascript, пиксельарт)

Мой интерес к разработке игр привел меня к идее реализовать логику с нуля, без движка.

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

До сих пор я работал с холстом лишь ограниченно, поэтому приведенный здесь код, безусловно, можно оптимизировать. Однако меня интересует, как можно оптимизировать 8-направленную систему.

В конечном итоге перемещается не пользователь, а фон. Это приводит к массовым рывкам из-за скорости движения и заданного количества пикселей, которые необходимо преодолеть.

По сути, у меня есть логика, позволяющая нормализовать векторы, но замеченные мною руководства по оптимизации не очень полезны.

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

Есть идеи, как оптимизировать эту демо-версию?

const game = document.querySelector('#game');
const context = game.getContext('2d');

const background = new Image();
background.src = 'https://i.imgur.com/82hX6qh.png';

const player = new Image();
player.src = 'https://i.imgur.com/LrL69By.png';

const backgroundHeight = 480;
const backgroundWidth = 840;
const playerHeight = 16;
const playerWidth = 12;

const initialGameWidth = 240;
const initialGameHeight = playerWidth * 10;

const cameraSpeed = 0.1;
const step = 1 / 60;

let x = backgroundWidth / 2; // Player Starting X
let y = backgroundHeight / 2; // Player Starting Y
let speed = 1;
let keymap = [];
let previousMs;
let cameraX = x;
let cameraY = y;

game.width = initialGameWidth;
game.height = initialGameHeight;

const handleScreens = () => {
    const scale = window.innerHeight / initialGameHeight;
    game.style.scale = scale;

    const gameHeight = Math.round(window.innerHeight / scale);
    const gameWidth = Math.round(window.innerWidth / scale);

    game.height = gameHeight % 2 ? gameHeight + 1 : gameHeight;
    game.width = gameWidth % 2 ? gameWidth + 1 : gameWidth;
};

handleScreens();
window.addEventListener('resize', () => handleScreens());

window.addEventListener('keydown', event => {
    let index = keymap.indexOf(event.key);

    if (index > -1) {
        keymap.splice(index, 1);
    }

    keymap.push(event.key);
});

window.addEventListener('keyup', event => {
    let index = keymap.indexOf(event.key);

    if (index > -1) {
        keymap.splice(index, 1);
    }
});

const handleCamera = (currentValue, destinationValue) => {
    return currentValue * (1 - cameraSpeed) + destinationValue * cameraSpeed;
};

const handleWork = () => {
    let tempChange = { x: 0, y: 0 };

    [...keymap].forEach(direction => {
        switch (direction) {
            case 'ArrowRight':
                tempChange.x = 1;
                break;
            case 'ArrowLeft':
                tempChange.x = -1;
                break;
            case 'ArrowUp':
                tempChange.y = -1;
                break;
            case 'ArrowDown':
                tempChange.y = 1;
                break;
        }
    });

    let angle = Math.atan2(tempChange.y, tempChange.x);

    if (tempChange.x !== 0) {
        x += Math.cos(angle) * speed;
    }

    if (tempChange.y !== 0) {
        y += Math.sin(angle) * speed;
    }

    cameraX = handleCamera(cameraX, x);
    cameraY = handleCamera(cameraY, y);

    //context.imageSmoothingEnabled = false;
    context.clearRect(0, 0, game.width, game.height);
    context.save();
    context.translate(-cameraX + (game.width / 2) - (playerWidth / 2), -cameraY + (game.height / 2) - (playerHeight / 2));
    context.drawImage(background, 0, 0, backgroundWidth, backgroundHeight);
    context.drawImage(player, 0, 0, playerWidth, playerHeight, x, y, playerWidth, playerHeight);
    context.restore();
};

const main = (timestampMs) => {
    if (previousMs == undefined) {
        previousMs = timestampMs;
    }

    let delta = (timestampMs - previousMs) / 1000;

    while (delta >= step) {
        handleWork();
        delta -= step;
    }

    previousMs = timestampMs - delta * 1000;

    requestAnimationFrame(main);
};

requestAnimationFrame(main);
* {
  margin: 0;
  padding: 0;
}

body {
  background-color: #222;
  display: flex;
  justify-content: center;
  align-items: center;
  overflow: hidden;
  height: 100vh;
  width: 100vw;
}

#game {
  -ms-interpolation-mode: nearest-neighbor;
      image-rendering: -moz-crisp-edges;
      image-rendering: pixelated;
}
<canvas id = "game"></canvas>

Вы ищете своего рода слайд на клавиатуре? Не совсем уверен, что означает плавность, поскольку мне это (с точки зрения FPS) кажется хорошим.

synnøve 17.07.2024 22:42

@synnøve При работе с большими экранами, например 1920x1080, смещение становится более заметным. Движение пикселей нечеткое. Если посмотреть на мой прокомментированный код, будет размытие. С моим текущим кодом, особенно диагонали, очень лестничные. Я видел некоторую реализацию с добавлением значения дельты в движении для большей «детализации», но я не знаю, подойдет ли этот вариант.

RoyBlunk 17.07.2024 23:11
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Улучшение производительности загрузки с помощью Google Tag Manager и атрибута Defer
Улучшение производительности загрузки с помощью Google Tag Manager и атрибута Defer
В настоящее время производительность загрузки веб-сайта имеет решающее значение не только для удобства пользователей, но и для ранжирования в...
Безумие обратных вызовов в javascript [JS]
Безумие обратных вызовов в javascript [JS]
Здравствуйте! Юный падаван 🚀. Присоединяйся ко мне, чтобы разобраться в одной из самых запутанных концепций, когда вы начинаете изучать мир...
Система управления парковками с использованием HTML, CSS и JavaScript
Система управления парковками с использованием HTML, CSS и JavaScript
Веб-сайт по управлению парковками был создан с использованием HTML, CSS и JavaScript. Это простой сайт, ничего вычурного. Основная цель -...
JavaScript Вопросы с множественным выбором и ответы
JavaScript Вопросы с множественным выбором и ответы
Если вы ищете платформу, которая предоставляет вам бесплатный тест JavaScript MCQ (Multiple Choice Questions With Answers) для оценки ваших знаний,...
2
2
79
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Если скорость равна 7, карта будет смещаться на 7 пикселей за раз, что может выглядеть немного заикающимся при частоте кадров 24 кадра в секунду. Итак, первое, что вы можете сделать, это установить FPS на 60 и скорость немного ниже.

const step = 1 / 60; 
const speed = 3;

Он по-прежнему немного заикается, но это также может быть вызвано тем, что браузер/процессор выполняет другие действия, которые задерживают вызовы requestAnimationframe.

Вы также можете использовать translate, чтобы переместить карту. Вы можете перевести весь холст, кроме персонажа игрока.

ctx.save();
ctx.translate(...); // movement
// draw the map here
ctx.restore();
// draw the player here

const game = document.querySelector('#game');
const context = game.getContext('2d');

const background = new Image();
background.src = 'https://i.imgur.com/Kjrpd2v.png';

const player = new Image();
player.src = 'https://i.imgur.com/PIT6zqs.png';

const initialGameWidth = 640;
const initialGameHeight = 360;

const step = 1 / 60;

let x = 3360 / 2;
let y = 1920 / 2;
let speed = 3;
let keymap = [];
let previousMs;

game.width = initialGameWidth;
game.height = initialGameHeight;

const handleScreens = () => {
    let scale = window.innerHeight / initialGameHeight;
    game.style.scale = scale < 1 ? 1 : scale;

    console.info(game.style.scale)

    game.height = window.innerHeight % 2 ? window.innerHeight - 1 : window.innerHeight;
    game.width = window.innerWidth % 2 ? window.innerWidth - 1 : window.innerWidth;
};

window.addEventListener('resize', () => handleScreens());
handleScreens();

window.addEventListener('keydown', event => {
    let index = keymap.indexOf(event.key);

    if (index > -1) {
        keymap.splice(index, 1);
    }

    keymap.push(event.key);
});

window.addEventListener('keyup', event => {

    let index = keymap.indexOf(event.key);

    if (index > -1) {
        keymap.splice(index, 1);
    }
});

const handleWork = (delta) => {
    let tempChange = { x: 0, y: 0 };

    [...keymap].forEach(direction => {
        switch (direction) {
            case 'ArrowRight':
                tempChange.x = 1;
                break;
            case 'ArrowLeft':
                tempChange.x = -1;
                break;
            case 'ArrowUp':
                tempChange.y = -1;
                break;
            case 'ArrowDown':
                tempChange.y = 1;
                break;
        }
    });

    let angle = Math.atan2(tempChange.y, tempChange.x);

    if (tempChange.x !== 0) {
        x += Math.cos(angle) * speed;
    }

    if (tempChange.y !== 0) {
        y += Math.sin(angle) * speed;
    }

    // const mapX = Math.floor((game.width / 2) - 24 - x) + delta;
    // const mapY = Math.floor((game.height / 2) - 32 - y) + delta;

    const mapX = (game.width / 2) - 24 - x;
    const mapY = (game.height / 2) - 32 - y;

    context.clearRect(0, 0, game.width, game.height);
    context.drawImage(background, 0, 0, 3360, 1920, mapX, mapY, 3360, 1920);
    context.drawImage(player, 0, 0, 48, 64, (game.width / 2) - 24, (game.height / 2) - 32, 48, 64);
};

const main = (timestampMs) => {
    if (previousMs == undefined) {
        previousMs = timestampMs;
    }

    let delta = (timestampMs - previousMs) / 1000;

    while (delta >= step) {
        handleWork(delta);
        delta -= step;
    }

    previousMs = timestampMs - delta * 1000;

    requestAnimationFrame(main);
};

requestAnimationFrame(main);
* {
  margin: 0;
  padding: 0;
}

body {
  background-color: #222;
  display: flex;
  justify-content: center;
  align-items: center;
  overflow: hidden;
  height: 100vh;
  width: 100vw;
}

#game {
  -ms-interpolation-mode: nearest-neighbor;
      image-rendering: -moz-crisp-edges;
      image-rendering: pixelated;
}
 <canvas id = "game"></canvas>

Вы правы, на это немного приятнее смотреть. Часто ли в играх с пиксельной графикой выставляют 60 FPS? Согласно определенной документации и форумам, неписаным стандартом является 24FPS. Однако остается проблема размытия фона из-за нормализации диагонального вектора. Есть ли здесь какой-то подход?

RoyBlunk 18.07.2024 22:19
Ответ принят как подходящий

Холст может интерпретировать только целые числа, поэтому он интерполирует десятичные числа. Эта интерполяция гарантирует, что пиксели будут размыты, поскольку делается попытка отобразить промежуточное состояние двух пикселей.

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

Однако, как только вы начинаете масштабировать непосредственно через холст, пиксели не только воспринимаются крупнее, но и улучшается интерполяция, поскольку теперь она имеет доступ к промежуточным пикселям. Это позволяет четко отображать промежуточные состояния.

Это можно реализовать через холст следующим образом:

context.imageSmoothingEnabled = false;
const scale = window.innerHeight / initialGameHeight;
context.scale(scale, scale);

Другие вопросы по теме