Привет! В этом посте я покажу вам, как создать простую 2D -игру с стрельбой в зомби, используя ванильный JS и холст HTML5. Весь код можно найти на моем GitHub Анкет
Живая демонстрация
Этот проект размещен в прямом эфире на Repl.it, так что посмотрите, что мы будем делать Здесь Анкет
Структура папки
Часто довольно запутанно иметь дело с такими длинными учебниками по кодированию, поэтому я предоставил простую структуру папок, которая может помочь. Я знаю, что мое именование файлов не самое лучшее (т.е. не капитализация имен файлов класса), но вы можете изменить их по мере необходимости.
index.html
css /
globals.css
index.css
js /
index.js
config.js
classes /
bullet.js
player.js
zombie.js
libs /
animate.js
input.js
pointer.js
utils.js
Кодовые фрагменты
Во многих кодовых учебных пособиях я видел, как люди ставят ... указывая, где были ранее написанные блоки кода. В этом проекте я не добавил и не сокращал кодовые блоки с помощью Ellipses. Все, что я написал, будет добавлено в предыдущий фрагмент кода, поэтому не удаляйте ничего, даже если вы не видите его в текущем фрагменте кода.
Помните, что если это сбивает с толку или вы хотите увидеть, где должны быть размещены функции, ознакомьтесь с кодом на GitHub.
HTML Mayout
Давайте начнем с создания нашего HTML -скелета. Все это действительно нужно иметь, это холст, минимальные стили и наш сценарий. Я не буду использовать WebPack в этом проекте, поэтому давайте воспользуемся преимуществами модулей браузера.
index.html
Shooter
До сих пор мы добавили базовые метатеги, холст и включили наши файлы CSS и JS.
Основные CSS
Вы можете пропустить эту часть на CSS. Я только что включил его в случай, если я расширил проект, например, добавление меню «Пуск». Обычно в моих проектах css/globals.css Содержит сбросы размера коробки и любые переменные для темы сайта. css/index.css Есть все остальное, что нужно для стиля index.html Анкет Опять же, этот шаг в основном ненужный, учитывая, что большая часть работы будет выполнена в JS.
css/globals.css
html, body {
height: 100%;
width: 100%;
padding: 0;
margin: 0;
box-sizing: border-box;
overflow: hidden; /* generally you don't mess with this but I don't want any scrolling regardless */
}
*, ::before, ::after {
box-sizing: inherit;
}
css/index.css
/* make the canvas wrapper expand to the entire page */
#app {
min-height: 100vh;
width: 100%;
}
/* make canvas expand to the entire page */
#app-scene {
height: 100%;
width: 100%;
}
JavaScript
Эта часть немного сложнее, поэтому я разбил ее на несколько разделов. Если вы застряли, вы всегда можете сравнить свою работу с кодом решения.
Конфигурация
Обычно вы захотите поместить переменные, которые изменяют поведение игры в config.js Анкет Например, вы можете указать скорость игрока или сколько точек хит -хэмби у зомби. Я оставлю вам специфику, так что все, что я экспортирую, это то, насколько большим должен быть холст (весь экран).
JS/config.js
const width = window.innerWidth
const height = window.innerHeight
export {
width,
height
}
Утилит
Библиотеки, такие как P5.JS, предоставляют множество встроенных функций, которые упрощают математику. Единственные функции, которые нам нужны, – это реализация случайный и расстояние Анкет
JS/libs/utils.js
const random = (min, max) => {
return (Math.random() * (max - min)) + min
}
const distance = (x1, y1, x2, y2) => {
let xx = Math.pow((x2 - x1), 2)
let yy = Math.pow((y2 - y1), 2)
return Math.sqrt(xx + yy)
}
export {
random,
distance
}
Анимация
Во -первых, нам нужно ссылаться на наш холст и настроить базовый игровой цикл. Основной процесс рендеринга и обновления будет настроен в JS/libs/animate.js , а затем импортируется для использования в js/index.js Анкет
Мы будем использовать window.requestanimationFrame Чтобы управлять игровой петлей. Я в значительной степени сорвал это с переполнения стека, но я сделаю все возможное, чтобы объяснить, что происходит.
Здесь мы инициализируем все переменные, которые мы будем использовать. Обновление это функция, которую мы перейдем в оживить Функция (см. Ниже), которую мы хотим запустить каждый кадр.
js/libs/animate.js
let interval, start, now, then, elapsed let update
StartAnimation Устанавливает нашу анимацию на 60 кадров в секунду и начинает AnimationLoop функция, которая рекурсивно вызывает с requestAnimationFrame .
js/libs/animate.js
const startAnimation = () => {
interval = 1000 / 60
then = Date.now()
start = then
animationLoop()
}
// recursively call animationLoop with requestAnimationFrame
const animationLoop = () => {
requestAnimationFrame(animationLoop)
now = Date.now()
elapsed = now - then
if(elapsed > interval) {
then = now - (elapsed % interval)
update()
}
}
Наконец, мы экспортируем функцию утилиты, чтобы установить Обновление и начать анимацию.
js/libs/animate.js
const animate = (u) => {
update = u
startAnimation()
}
export default animate
Здесь мы изменяем размер холста и получаем контекст холста, позволяя нам рисовать элементы на экране. Тогда мы оживляем пустое Обновление функция, которую мы будем заполнять очень скоро.
js/index.js
import animate from "./libs/animate.js"
import { width, height } from "./config.js"
// get the canvas and context
const canvas = document.getElementById("app-scene")
const ctx = canvas.getContext("2d")
Object.assign(canvas, {
width, height
})
const update = () => {
ctx.clearRect(0, 0, width, height) // refreshes the background
}
animate(update)
Игрок
Если вы бросите Консоль.log в Обновление Вы увидите, что он неоднократно запускается, но на экране ничего не нарисовано. Пришло время добавить игрока, которого мы можем контролировать!
На данный момент я инициализации класса с некоторыми переменными по умолчанию и пустыми функциями.
JS/Classes/Player.js
import { width, height } from "../config.js"
class Player {
vector = {
x: width / 2,
y: height / 2
}
speed = 2
radius = 20
angle = - Math.PI / 2
rotate() {}
move() {}
update() {
this.move()
}
render(ctx) {}
}
export default Player
Рендеринг игрока
В Player.Render Мы укажем, как должен выглядеть персонаж в нашей игре. Я не использую блюд, и я не профессионал в разработке активов, поэтому наш игрок буквально будет мячом цвета кожи.
Казалось бы, случайный -2 или +5 используется для регулировки расположения рук и пистолета, поэтому поиграйте с координатами, которые я передаю в функции рисования. Многое из того, что я сделал, чтобы игрок выглядел порядочным, это угадайте и проверьте.
JS/Classes/Player.js
render(ctx) {
// rotation logic (doesn't do anything for now)
ctx.save()
let tX = this.vector.x
let tY = this.vector.y
ctx.translate(tX, tY)
ctx.rotate(this.angle)
ctx.translate(-tX, -tY)
// Draw a circle as the body
ctx.beginPath()
ctx.fillStyle = "#ffe0bd"
ctx.arc(this.vector.x, this.vector.y, this.radius, 0, Math.PI * 2)
ctx.fill()
// Draw a black rectangle as the "gun"
ctx.beginPath()
ctx.fillStyle = "#000"
ctx.rect(this.vector.x + this.radius + 15, this.vector.y - 5, 25, 10)
ctx.fill()
// Specify how the hands should look
ctx.beginPath()
ctx.strokeStyle = "#ffe0bd"
ctx.lineCap = "round"
ctx.lineWidth = 4
// Right Hand
ctx.moveTo(this.vector.x + 5, this.vector.y + this.radius - 2)
ctx.lineTo(this.vector.x + this.radius + 15, this.vector.y + 5)
ctx.stroke()
// Left Hand
ctx.moveTo(this.vector.x + 5, this.vector.y - this.radius + 2)
ctx.lineTo(this.vector.x + this.radius + 15, this.vector.y - 5)
ctx.stroke()
// also part of the rotation logic
ctx.restore()
}
На экране!
После инициализации класса игроков мы можем обновить и отобразить его в пределах оживить функция Имейте в виду, что я только вставлю соответствующие части кода, поэтому сохраняйте все, что мы написали ранее.
js/index.js
import Player from "./classes/player.js"
const player = new Player()
const update = () => {
player.update()
player.render(ctx)
}
animate(update)
Если все прошло хорошо, теперь вы должны увидеть мяч с прямоугольником на экране.
Движение
Я экспериментировал с Keydown Событие, но я заметил, что не могу сразу перенести игрока в нескольких направлениях. Я взломал простой обработчик ввода, который вы можете использовать, чтобы помочь справиться с этой проблемой.
JS/libs/input.js
let keymap = []
window.addEventListener("keydown", e => {
let { key } = e
if(!keymap.includes(key)) {
keymap.push(key)
}
})
window.addEventListener("keyup", e => {
let { key } = e
if(keymap.includes(key)) {
keymap.splice(keymap.indexOf(key), 1)
}
})
const key = (x) => {
return keymap.includes(x)
}
// now, we can use key("w") to see if w is still being pressed
export default key
По сути, мы добавляем ключи к Кеймап Когда они нажимают, и удалите их, когда они будут освобождены. Вы могли бы охватить еще несколько краевых случаев, очистив KeyMap, когда пользователь переключается на другую вкладку, но я был ленив.
Вернувшись в класс игрока, нам нужно обнаружить, когда пользователь нажимает на WASD и соответственно изменить позицию. Я также сделал элементарную граничную систему, чтобы не допустить выхода на экран.
JS/Classes/Player.js
import key from "../libs/input.js"
class Player {
move() {
if(key("w") && this.vector.y - this.speed - this.radius > 0) {
this.vector.y -= this.speed
}
if(key("s") && this.vector.y + this.speed + this.radius < height) {
this.vector.y += this.speed
}
if(key("a") && this.vector.x - this.speed - this.radius > 0) {
this.vector.x -= this.speed
}
if(key("d") && this.vector.x + this.speed + this.radius < width) {
this.vector.x += this.speed
}
}
}
Ротация
Игрок может перемещаться, но пистолет только указывает вверх. Чтобы исправить это, нам нужно найти местоположение мыши и повернуть игрока к нему.
Технически нам не нужно получать позицию холста, потому что он покрывает весь экран. Тем не менее, это позволяет нам использовать ту же функцию, даже если мы изменим местоположение холста.
js/libs/pointer.js
const pointer = (canvas, event) => {
const rect = canvas.getBoundingClientRect()
const x = event.clientX - rect.left
const y = event.clientY - rect.top
return {
x, y
}
}
export default pointer
Игрок должен вращаться к координатам указателя, поэтому давайте быстро добавим это. Мы уже добавили логику, чтобы учесть угол игрока, поэтому нам не нужно ничего менять в Player.Render Анкет
JS/Classes/Player.js
// destructure the pointer coords
rotate({ x, y }) {
let dy = y - this.vector.y
let dx = x - this.vector.x
// essentially get the angle from the player to the cursor in radians
this.angle = Math.atan2(dy, dx)
}
Но ждать! Когда мы обновляем демонстрацию, игрок не смотрит на нашу мышь. Это потому, что мы никогда не слушаем MouseMove событие, чтобы получить координаты мыши.
js/index.js
import pointer from "./libs/pointer.js"
document.body.addEventListener("mousemove", (e) => {
let mouse = pointer(canvas, e)
player.rotate(mouse)
})
Теперь у нас есть движущийся игрок, который может осмотреть вокруг.
Зомби
Как и игрок, давайте создадим класс зомби. Много кода зомби будет выглядеть очень знакомо. Однако вместо того, чтобы вращаться и перемещаться в зависимости от ввода пользователя, он просто будет следовать за игроком.
Зомби будут появляться случайно справа. Поскольку они всегда должны столкнуться с игроком, мы создадим функцию вращения, которая принимает класс игрока и завоевывает их позицию.
JS/Classe/Zombie.js
import { width, height } from "../config.js"
import { random } from "../libs/utils.js"
class Zombie {
speed = 1.1
radius = 20
health = 5
constructor(player) {
this.vector = {
x: width + this.radius,
y: random(-this.radius, height + this.radius)
}
this.rotate(player)
}
rotate(player) {}
update(player, zombies) {
this.rotate(player)
}
render(ctx) {}
}
export default Zombie
Рендеринг зомби
Зомби будут зелеными шариками с растянутыми руками. Вращающаяся логика, тело и руки – это по сути те же вещи, которые можно найти в Player.Render Анкет
JS/Classe/Zombie.js
render(ctx) {
ctx.save()
let tX = this.vector.x
let tY = this.vector.y
ctx.translate(tX, tY)
ctx.rotate(this.angle)
ctx.translate(-tX, -tY)
ctx.beginPath()
ctx.fillStyle = "#00cc44"
ctx.arc(this.vector.x, this.vector.y, this.radius, 0, Math.PI * 2)
ctx.fill()
// Hands
ctx.beginPath()
ctx.strokeStyle = "#00cc44"
ctx.lineCap = "round"
ctx.lineWidth = 4
// Right Hand
ctx.moveTo(this.vector.x + 5, this.vector.y + this.radius - 2)
ctx.lineTo(this.vector.x + this.radius + 15, this.vector.y + this.radius - 5)
ctx.stroke()
// Left Hand
ctx.moveTo(this.vector.x + 5, this.vector.y - this.radius + 2)
ctx.lineTo(this.vector.x + this.radius + 15, this.vector.y - this.radius + 5)
ctx.stroke()
ctx.restore()
}
На экране!
Вы можете инициализировать зомби, как мы с игроком, но давайте храним их как массив, если мы захотим добавить больше.
JS/Classe/Zombie.js
import Zombie from "./classes/zombie.js"
const player = new Player()
const zombies = [ new Zombie(player) ]
const update = () => {
zombies.forEach(zombie => {
zombie.update(player, zombies)
zombie.render(ctx)
})
player.update()
player.render(ctx)
}
animate(update)
Следуйте за игроком
Зомби привлекаются к человеческому мозгу. К сожалению, зомби, который мы только что сделали, только что сидит за экраном. Давайте начнем с того, что заставь зомби следовать за игроком. Основные функции, которые позволяют этому произойти, – это Zombie.rotate (укажите на игрока) и Zombie.update (Вызовы вращаются и движется в общем направлении координат игрока).
Если вы не понимаете Math.cos или Math.sin , интуитивно это имеет смысл, потому что Cosine относится к X и Sine относится к Y. Мы в основном преобразовываем угол в x и y, чтобы применить его к вектору положения зомби.
JS/Classe/Zombie.js
rotate(player) {
let dy = player.vector.y - this.vector.y
let dx = player.vector.x - this.vector.x
this.angle = Math.atan2(dy, dx)
}
update(player, zombies) {
this.rotate(player)
this.vector.x += Math.cos(this.angle) * this.speed
this.vector.y += Math.sin(this.angle) * this.speed
}
Хотя мы еще не внедрили систему стрельбы, мы хотим удалить зомби, когда его здоровье достигает 0. Давайте изменим функцию обновления, чтобы разбивать мертвые зомби.
JS/Classe/Zombie.js
update(player, zombies) {
if(this.health <= 0) {
zombies = zombies.splice(zombies.indexOf(this), 1)
return
}
this.rotate(player)
this.vector.x += Math.cos(this.angle) * this.speed
this.vector.y += Math.sin(this.angle) * this.speed
}
Пуль
Зомби атакуют! Но что мы делаем? У нас нет боеприпасов! Нам нужно сделать класс пули, чтобы мы могли начать убивать монстров.
Когда мы призываем к новой пуле, нам нужно выяснить, с чего должна начаться пуля ( bullet.vector ), и в каком направлении следует начать заголовок ( bullet.Angle ). * 40 Рядом с векторной частью сдвигает пулю возле пистолета, а не появляется прямо на вершине игрока.
JS/Classes/Bullet.js
import { width, height } from "../config.js"
import { distance } from "../libs/utils.js"
class Bullet {
radius = 4
speed = 10
constructor(x, y, angle) {
this.angle = {
x: Math.cos(angle),
y: Math.sin(angle)
}
this.vector = {
x: x + this.angle.x * 40,
y: y + this.angle.y * 40
}
}
boundary() {}
update(bullets, zombies) {
this.vector.x += this.angle.x * this.speed
this.vector.y += this.angle.y * this.speed
}
render(ctx) {}
}
export default Bullet
Рендеринг пуль
Пуля будет черным кругом. Вы можете изменить это на прямоугольник или другую форму, но имейте в виду, что вы захотите повернуть его в зависимости от угла.
JS/Classes/Bullet.js
render(ctx) {
ctx.beginPath()
ctx.arc(this.vector.x, this.vector.y, this.radius, 0, Math.PI * 2)
ctx.fillStyle = "#000"
ctx.fill()
}
Граница
Пули должны быть удалены, когда они либо попадают в зомби, либо оставляют вид экрана. Давайте сначала реализуем пограничное столкновение. Bullet.boundary Следует указать, выходит ли пуля за пределы, а затем удалить ее из массива пулей.
JS/Classes/Bullet.js
boundary() {
return (this.vector.x > width + this.radius ||
this.vector.y > height + this.radius ||
this.vector.x < 0 - this.radius ||
this.vector.y < 0 - this.radius)
}
update(bullets, zombies) {
if(this.boundary()) {
bullets = bullets.splice(bullets.indexOf(this), 1)
return
}
this.vector.x += this.angle.x * this.speed
this.vector.y += this.angle.y * this.speed
}
Нажмите, чтобы стрелять
Каждый раз, когда мы нажимаем на экран, мы должны выпустить новую пулю. После импорта класса пули в основной сценарий мы сделаем Пули Массив, который мы можем выдвинуть новую пулю на каждый раз, когда пользователь щелкает на экране. Таким образом, мы можем пройти и обновить каждую пулю.
Если вы помните чуть выше, нам нужно пройти в пули и зомби, массивные непосредственно в Bullet.update Функция, чтобы мы могли удалять пули по мере необходимости.
js/index.js
import Bullet from "./classes/bullet.js"
const bullets = []
document.body.addEventListener("click", () => {
bullets.push(
new Bullet(player.vector.x, player.vector.y, player.angle)
)
})
const update = () => {
bullets.forEach(bullet => {
bullet.update(bullets, zombies)
bullet.render(ctx)
})
}
animate(update)
Убейте зомби!
На данный момент пули проходят прямо через зомби.
Мы можем пройти через каждого зомби и пули и проверить расстояние между ними. Если расстояние ниже радиуса зомби, наша пуля поразила цель, и нам нужно уменьшить HP зомби и удалить пулю.
JS/Classes/Bullet.js
update(bullets, zombies) {
if(this.boundary()) {
bullets = bullets.splice(bullets.indexOf(this), 1)
return
}
for(const bullet of bullets) {
for(const zombie of zombies) {
let d = distance(zombie.vector.x, zombie.vector.y, this.vector.x, this.vector.y)
if(d < zombie.radius) {
bullets = bullets.splice(bullets.indexOf(this), 1)
zombie.health --
return
}
}
}
this.vector.x += this.angle.x * this.speed
this.vector.y += this.angle.y * this.speed
}
Попробуйте стрелять в зомби 5 раз. Надеюсь, пули и зомби исчезнут.
Бонус: бесконечные волны
Один зомби скучен. Как насчет того, как мы появляемся в зомби каждые три секунды? js/index.js
setInterval(() => {
zombies.push(new Zombie(player))
}, 3 * 1000)
Закрытие
Теперь у нас есть полностью функциональная игра с стрельбой в зомби. Надеемся, что это дало вам краткое представление о разработке игры с холстом HTML5. В настоящее время ничего не происходит, когда зомби касается вас, но не должно быть слишком сложно реализовать планку HP Player (посмотрите на код столкновения пули и столкновения с зомби). Я с нетерпением жду, как вы расширяете или оптимизируете эту игру!
Оригинал: “https://dev.to/phamn23/simple-zombie-shooter-49d”