Думаю, все знают эту игру.
Эта штука все равно очень интересная, будь то Super Mario, Contra или King of Glory и Onmyoji.
Конечно, в этой статье речь не идет о такой мощной игре, тут простая игра.
Сначала дайте ему имя, назовем его «Битва шаров».
Эй, это легко понять
как играть
Любой, кто входит в игру, вводит имя, а затем может подключиться, чтобы войти в игру и управлять маленьким мячом.
Вы можете использовать этот шар для выполнения некоторых действий, таких как: перемещение, стрельба пулями.
Зарабатывайте очки, убивая других игроков, и поднимайтесь в таблице лидеров.
На самом деле у этого типа игр есть унифицированное название, называемое IO games, и таких игр на этом сайте большое количество:iogames.space/
Адрес этой игры на гитхабе:GitHub.com/Лев ET1224/…
онлайн опыт:http://120.77.44.111:3000/
Демо гифка:
Готов к работе
Чтобы сделать эту игру первой, нам понадобятся следующие технологии:
- внешний интерфейс
- Socket.io
- Webpack
- задняя часть
- Node
- Socket.io
- express
- ...
И нужно иметь определенное представление о следующих технологиях:
- Canvas
- объектно-ориентированный
- ES6
- Node
- Promise
На самом деле, я хотел использовать
deno
а такжеts
для разработки, а т.к. я наполовину знаком с этими двумя технологиями, то показывать не буду.
Архитектура игры
Что нужно сделать серверной службе:
- Сохраните сгенерированный игровой объект и отправьте его во внешний интерфейс.
- Получать операции с внешними игроками и обрабатывать данные для игровых объектов
Передняя часть должна сделать следующее:
- Получать данные, отправленные серверной частью, и отображать их.
- Отправить действия игрока на сервер
Это также типичный способ синхронизации состояний для разработки игр.
Построение и развитие серверных сервисов
Поскольку интерфейс управляется данными серверной части, мы сначала разрабатываем серверную часть.
Создайте экспресс-сервис
Сначала нам нужно скачатьexpress
, введите следующую команду в корневом каталоге:
// 创建一个package.json文件
> npm init
// 安装并且将其置入package.json文件中的依赖中
> npm install express socket.io --save
// 安装并置入package.json的开发依赖中
> npm install cross-env nodemon --save-dev
Здесь мы также можем использовать cnpm для установки
Затем создайте сумасшедшие папки и файлы в корневом каталоге.
Мы можем получить вышеуказанные файлы.
Объясните, что такое каждый:
-
public
хранить некоторые ресурсы -
src
разработать код-
client
интерфейсный код -
servers
внутренний код-
core
основной код -
objects
Игроки, реквизит и т.д.
-
-
shared
Внешние и внутренние общие константы
-
написать базовый код
Тогда мыserver.js
Напишите соответствующий код для запуска службы в формате .
// server.js
// 引入各种模块
const express = require('express')
const socketio = require('socket.io');
const app = express();
const Socket = require('./core/socket');
const Game = require('./core/game');
// 启动服务
const port = process.env.PORT || 3000;
const server = app.listen(3000, () => {
console.log('Server Listening on port: ' + port)
})
// 实例游戏类
const game = new Game;
// 监听socket服务
const io = socketio(server)
// 将游戏以及io传入创建的socket类来统一管理
const socket = new Socket(game, io);
// 监听连接进入游戏的回调
io.on('connect', item => {
socket.listen(item)
})
В приведенном выше коде также представлены два других файла.core/game
,core/socket
.
Я грубо написал код в этих двух файлах.
// core/game.js
class Game{
constructor(){
// 保存玩家的socket信息
this.sockets = {}
// 保存玩家的游戏对象信息
this.players = {};
// 子弹
this.bullets = [];
// 最后一次执行时间
this.lastUpdateTime = Date.now();
// 是否发送给前端数据,这里将每两帧发送一次数据
this.shouldSendUpdate = false;
// 游戏更新
setInterval(this.update.bind(this), 1000 / 60);
}
update(){
}
// 玩家加入游戏
joinGame(){
}
// 玩家断开游戏
disconnect(){
}
}
module.exports = Game;
// core/socket.js
const Constants = require('../../shared/constants')
class Socket{
constructor(game, io){
this.game = game;
this.io = io;
}
listen(){
// 玩家成功连接socket服务
console.log(`Player connected! Socket Id: ${socket.id}`)
}
}
module.exports = Socket
существуетcore/socket
Ввел в константный файл, посмотрим, как я его в нем определяю.
// shared/constants.js
module.exports = Object.freeze({
// 玩家的数据
PLAYER: {
// 最大生命
MAX_HP: 100,
// 速度
SPEED: 500,
// 大小
RADUIS: 50,
// 开火频率, 0.1秒一发
FIRE: .1
},
// 子弹
BULLET: {
// 子弹速度
SPEED: 1500,
// 子弹大小
RADUIS: 20
},
// 道具
PROP: {
// 生成时间
CREATE_TIME: 10,
// 大小
RADUIS: 30
},
// 地图大小
MAP_SIZE: 5000,
// socket发送消息的函数名
MSG_TYPES: {
JOIN_GAME: 1,
UPDATE: 2,
INPUT: 3
}
})
Метод Object.freeze() замораживает объект. Замороженный объект больше не может быть изменен; когда объект заморожен, к объекту нельзя добавить новые свойства, существующие свойства нельзя удалить, а перечисляемость, конфигурируемость и возможность записи существующих свойств объекта нельзя изменить. и не может изменить значение существующего свойства. Кроме того, после замораживания объекта прототип объекта не может быть изменен. замораживание() возвращает тот же объект, который был передан. - МДН
Благодаря коду вышеуказанных четырех файлов у нас уже есть внутренняя структура службы с основными функциями.
Давайте начнем дальше.
Создать команду запуска
существуетpackage.json
Напишите команду запуска в формате .
// package.json
{
// ...
"scripts": {
"dev": "cross-env NODE_ENV=development nodemon src/servers/server.js",
"start": "cross-env NODE_ENV=production nodemon src/servers/server.js"
}
//..
}
две команды здесьdev
а такжеstart
все использованоcross-env
а такжеnodemon
, вот объяснение:
-
cross-env
Установите переменные среды, здесь вы можете видеть, что за этим стоит еще одинNODE_ENV=development/production
, чтобы определить, находится ли он в режиме разработки. -
nodemon
Проще говоря, это прослушивание изменений файлов и сброс службы Node.
Давайте начнем службу
Выполните следующую команду, чтобы включить режим разработки.
> npm run dev
Вы видите, что мы успешно запустили сервис и прослушали его.3000
порт.
В обслуживании мы контрейлерныеsocket
сервис, как проверить работает ли он?
Итак, давайте просто создадим переднюю часть.
Webpack создает интерфейсные файлы
Когда мы разрабатываем интерфейс, если мы используем модульность, разработка будет более плавной, а также есть упаковка и сжатие производственной среды, которые можно использовать.Webpack
.
Наша упаковка имеет две разные среды, одну для производства и одну для разработки, поэтому нам нужны двеwebpack
конфигурационный файл.
Конечно, было бы немного глупо писать сразу два, а повторяющийся контент мы будем деконструировать.
Создаем в корневом каталогеwebpack.common.js
,webpack.dev.js
,webpack.prod.js
три файла.
Команда модуля отложенной установки для этого шага:
npm install @babel/core @babel/preset-env babel-loader css-loader html-webpack-plugin mini-css-extract-plugin optimize-css-assets-webpack-plugin terser-webpack-plugin webpack webpack-dev-middleware webpack-merge webpack-cli --save-dev
// webpack.common.js
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: {
game: './src/client/index.js',
},
// 将打包文件输出到dist文件夹
output: {
filename: '[name].[contenthash].js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
// 使用babel解析js
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
options: {
presets: ['@babel/preset-env'],
},
},
},
// 将js中的css抽出来
{
test: /\.css$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
},
'css-loader',
],
},
],
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].[contenthash].css',
}),
// 将处理后的js以及css置入html中
new HtmlWebpackPlugin({
filename: 'index.html',
template: 'src/client/html/index.html',
}),
],
};
Приведенный выше код уже работаетcss
так же какjs
файл, затем мы назначаем егоdevelopment
а такжеproduction
в, из нихproduction
будет сжатjs
а такжеcss
так же какhtml
.
// webpack.dev.js
const { merge } = require('webpack-merge')
const common = require('./webpack.common')
module.exports = merge(common, {
mode: 'development'
})
// webpack.prod.js
const { merge } = require('webpack-merge')
const common = require('./webpack.common')
// 压缩js的插件
const TerserJSPlugin = require('terser-webpack-plugin')
// 压缩css的插件
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin')
module.exports = merge(common, {
mode: 'production',
optimization: {
minimizer: [new TerserJSPlugin({}), new OptimizeCssAssetsPlugin({})]
}
})
Выше были определены три различныхwebpack
файлы, так как вы их используете?
Прежде всего, в режиме разработки нам нужно автоматически упаковать код после модификации кода, тогда код выглядит следующим образом:
// src/servers/server.js
const webpack = require('webpack')
const webpackDevMiddleware = require('webpack-dev-middleware')
const webpackConfig = require('../../webpack.dev')
// 前端静态文件
const app = express();
app.use(express.static('public'))
if(process.env.NODE_ENV === 'development'){
// 这里是开发模式
// 这里使用了webpack-dev-middleware的中间件,作用就是代码改动就使用webpack.dev的配置进行打包文件
const compiler = webpack(webpackConfig);
app.use(webpackDevMiddleware(compiler));
} else {
// 上线环境就只需要展示打包后的文件夹
app.use(express.static('dist'))
}
следующийpackage.json
Добавьте в него соответствующую команду.
{
//...
"scripts": {
"build": "webpack --config webpack.prod.js",
"start": "npm run build && cross-env NODE_ENV=production nodemon src/servers/server.js"
},
//...
}
Далее попробуемdev
а такжеstart
эффект.
можно увидеть с помощьюnpm run dev
После команды не только запускается служба, но и запаковываются файлы интерфейса.
Попробуйте сноваnpm run start
.
Вы также можете видеть, что сначала упаковывается файл, а затем запускается служба.
Давайте посмотрим на упакованный файл.
Проверьте, действителен ли сокет
Позвольте мне сначала установить переднюю частьsocket.io
.
> npm install socket.io-client --save
Затем напишите файл ввода внешнего файла:
// src/client/index.js
import { connect } from './networking'
Promise.all([
connect()
]).then(() => {
}).catch(console.error)
Вы можете видеть, что в приведенном выше коде я представил еще один файлnetworking
, Давайте взглянем:
// src/client/networking
import io from 'socket.io-client'
// 这里判断是否是https,如果是https就需要使用wss协议
const socketProtocal = (window.location.protocol.includes('https') ? 'wss' : 'ws');
// 这里就进行连接并且不重新连接,这样可以制作一个断开连接的功能
const socket = io(`${socketProtocal}://${window.location.host}`, { reconnection: false })
const connectPromise = new Promise(resolve => {
socket.on('connect', () => {
console.log('Connected to server!');
resolve();
})
})
export const connect = onGameOver => {
connectPromise.then(()=> {
socket.on('disconnect', () => {
console.log('Disconnected from server.');
})
})
}
Приведенный выше код является соединениемsocket
, автоматически получит адрес, а затем подключится черезPromise
перейти кindex.js
, чтобы файл записи мог знать об успешном подключении.
Теперь давайте взглянем на главную страницу.
Хорошо видно, что на переднем и заднем концах есть соответствующие подсказки для успешного подключения.
Создавать игровые объекты
Давайте теперь определим игровой объект в нашей игре.
Во-первых, в игре будет четыре разных игровых объекта:
-
Player
персонаж игрока -
Prop
реквизит -
Bullet
пуля
Давайте сделаем это один за другим.
Во-первых, все они принадлежат объектам, поэтому я определяю для них родительский класс.Item
:
// src/servers/objects/item.js
class Item{
constructor(data = {}){
// id
this.id = data.id;
// 位置
this.x = data.x;
this.y = data.y;
// 大小
this.w = data.w;
this.h = data.h;
}
// 这里是物体每帧的运行状态
update(dt){
}
// 格式化数据以方便发送数据给前端
serializeForUpdate(){
return {
id: this.id,
x: this.x,
y: this.y,
w: this.w,
h: this.h
}
}
}
module.exports = Item;
Приведенный выше класс — это класс, который наследуют все игровые объекты, и он определяет основные свойства каждого элемента в игровом мире.
Далееplayer
,Prop
,Bullet
определено.
// src/servers/objects/player.js
const Item = require('./item')
const Constants = require('../../shared/constants')
/**
* 玩家对象类
*/
class Player extends Item{
constructor(data){
super(data);
this.username = data.username;
this.hp = Constants.PLAYER.MAX_HP;
this.speed = Constants.PLAYER.SPEED;
// 击败分值
this.score = 0;
// 拥有的buffs
this.buffs = [];
}
update(dt){
}
serializeForUpdate(){
return {
...(super.serializeForUpdate()),
username: this.username,
hp: this.hp,
buffs: this.buffs.map(item => item.type)
}
}
}
module.exports = Player;
Затем идут определения реквизита и пуль.
// src/servers/objects/prop.js
const Item = require('./item')
/**
* 道具类
*/
class Prop extends Item{
constructor(){
super();
}
}
module.exports = Prop;
// src/servers/objects/bullet.js
const Item = require('./item')
/**
* 子弹类
*/
class Bullet extends Item{
constructor(){
super();
}
}
module.exports = Bullet
Все вышеперечисленное является простыми определениями, и контент будет постепенно добавляться по мере разработки.
добавить отправку событий
Хотя приведенный выше код был определен, его все же необходимо использовать, поэтому здесь мы разработаем метод для их использования.
После того, как игрок вводит имя для присоединения к игре, ему необходимо сгенерироватьPlayer
игровой объект.
// src/servers/core/socket.js
class Socket{
// ...
listen(socket){
console.log(`Player connected! Socket Id: ${socket.id}`);
// 加入游戏
socket.on(Constants.MSG_TYPES.JOIN_GAME, this.game.joinGame.bind(this.game, socket));
// 断开游戏
socket.on('disconnect', this.game.disconnect.bind(this.game, socket));
}
// ...
}
затем вgame.js
добавить связанную логику.
// src/servers/core/game.js
const Player = require('../objects/player')
const Constants = require('../../shared/constants')
class Game{
// ...
update(){
const now = Date.now();
// 现在的时间减去上次执行完毕的时间得到中间间隔的时间
const dt = (now - this.lastUpdateTime) / 1000;
this.lastUpdateTime = now;
// 更新玩家人物
Object.keys(this.players).map(playerID => {
const player = this.players[playerID];
player.update(dt);
})
if(this.shouldSendUpdate){
// 发送数据
Object.keys(this.sockets).map(playerID => {
const socket = this.sockets[playerID];
const player = this.players[playerID];
socket.emit(
Constants.MSG_TYPES.UPDATE,
// 处理游戏中的对象数据发送给前端
this.createUpdate(player)
)
})
this.shouldSendUpdate = false;
} else {
this.shouldSendUpdate = true;
}
}
createUpdate(player){
// 其他玩家
const otherPlayer = Object.values(this.players).filter(
p => p !== player
);
return {
t: Date.now(),
// 自己
me: player.serializeForUpdate(),
others: otherPlayer,
// 子弹
bullets: this.bullets.map(bullet => bullet.serializeForUpdate())
}
}
// 玩家加入游戏
joinGame(socket, username){
this.sockets[socket.id] = socket;
// 玩家位置随机生成
const x = (Math.random() * .5 + .25) * Constants.MAP_SIZE;
const y = (Math.random() * .5 + .25) * Constants.MAP_SIZE;
this.players[socket.id] = new Player({
id: socket.id,
username,
x, y,
w: Constants.PLAYER.WIDTH,
h: Constants.PLAYER.HEIGHT
})
}
disconnect(socket){
delete this.sockets[socket.id];
delete this.players[socket.id];
}
}
module.exports = Game;
Здесь мы разрабатываем присоединение и выход игроков, а такжеPlayer
Данные объекта обновляются, а данные игры отправляются.
Теперь, когда внутренняя служба имеет возможность предоставлять содержимое внешнему интерфейсу, давайте приступим к разработке внешнего интерфейса.
Фронтенд разработка интерфейса
Вышеизложенное позволяет нам разработать серверную службу с базовой функциональностью.
Далее давайте разработаем функции, связанные с внешним интерфейсом.
Получение данных, отправленных серверной частью
Давайте посмотрим, как выглядят данные, отправленные из бэкенда.
Сначала напишите метод получения во внешнем интерфейсе.
// src/client/networking.js
import { processGameUpdate } from "./state";
export const connect = onGameOver => {
connectPromise.then(()=> {
// 游戏更新
socket.on(Constants.MSG_TYPES.UPDATE, processGameUpdate);
socket.on('disconnect', () => {
console.log('Disconnected from server.');
})
})
}
export const play = username => {
socket.emit(Constants.MSG_TYPES.JOIN_GAME, username);
}
// src/client/state.js
export function processGameUpdate(update){
console.log(update);
}
// src/client/index.js
import { connect, play } from './networking'
Promise.all([
connect()
]).then(() => {
play('test');
}).catch(console.error)
Приведенный выше код позволяет нам войти на страницу и присоединиться к игре напрямую, перейдите на страницу, чтобы увидеть эффект.
Напишите интерфейс игры
Мы сначалаhtml
Отредактируйте код.
// src/client/html/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>球球作战</title>
</head>
<body>
<canvas id="cnv"></canvas>
<div id="home">
<h1>球球作战</h1>
<p class="text-secondary">一个简简单单的射击游戏</p>
<hr>
<div class="content">
<div class="key">
<p>
<code>W</code> 向上移动
</p>
<p>
<code>S</code> 向下移动
</p>
<p>
<code>A</code> 向左移动
</p>
<p>
<code>D</code> 向右移动
</p>
<p>
<code>鼠标左键</code> 发射子弹
</p>
</div>
<div class="play hidden">
<input type="text" id="username-input" placeholder="名称">
<button id="play-button">开始游戏</button>
</div>
<div class="connect">
<p>连接服务器中...</p>
</div>
</div>
</div>
</body>
</html>
затем вindex.js
импортировать вcss
.
// src/client/index.js
import './css/bootstrap-reboot.css'
import './css/main.css'
существуетsrc/client/css
Создайте соответствующий файл вbootstrap-reboot
даbootstrap
Файл для сброса основного стиля, этот можно скачать в интернете, так как он слишком длинный, эта статья не будет размещена.
существуетmain.css
Напишите соответствующий стиль в .
// src/client/css/main.css
html, body {
margin: 0;
padding: 0;
overflow: hidden;
width: 100%;
height: 100vh;
background: linear-gradient(to right bottom, rgb(154, 207, 223), rgb(100, 216, 89));
}
.hidden{
display: none !important;
}
#cnv{
width: 100%;
height: 100%;
}
.text-secondary{
color: #666;
}
code{
color: white;
background: rgb(236, 72, 72);
padding: 2px 10px;
border-radius: 5px;
}
hr {
border: 0;
border-top: 1px solid rgba(0, 0, 0, 0.1);
margin: 1rem 0;
width: 100%;
}
button {
font-size: 18px;
outline: none;
border: none;
color: black;
background-color: transparent;
padding: 5px 20px;
border-radius: 3px;
transition: background-color 0.2s ease;
}
button:hover {
background-color: rgb(141, 218, 134);
color: white;
}
button:focus {
outline: none;
}
#home p{
margin-bottom: 5px;
}
#home{
position: fixed;
top: 50%;
left: 50%;
transform: translateY(-50%) translateX(-50%);
padding: 20px 30px;
background-color: white;
display: flex;
flex-direction: column;
align-items: center;
border-radius: 5px;
text-align: center;
}
#home input {
font-size: 18px;
outline: none;
border: none;
border-bottom: 1px solid #dedede;
margin-bottom: 5px;
padding: 3px;
text-align: center;
}
#home input:focus{
border-bottom: 1px solid #8d8d8d;
}
#home .content{
display: flex;
justify-content: space-between;
align-items: center;
}
#home .content .play{
width: 200px;
margin-left: 50px;
}
#home .content .connect{
margin-left: 50px;
}
Наконец, мы можем получить эффект следующей картинки.
Напишите логику запуска игры
Давайте создадимutil.js
для хранения некоторых служебных функций.
// src/client/util.js
export function $(elem){
return document.querySelector(elem)
}
затем вindex.js
Напишите соответствующий логический код в формате .
// src/client/index.js
import { connect, play } from './networking'
import { $ } from './util'
Promise.all([
connect()
]).then(() => {
// 隐藏连接服务器显示输入框及按键
$('.connect').classList.add('hidden')
$('.play').classList.remove('hidden')
// 并且默认聚焦输入框
$('#home input').focus();
// 游戏开始按钮监听点击事件
$('#play-button').onclick = () => {
// 判断输入框的值是否为空
let val = $('#home input').value;
if(val.replace(/\s*/g, '') === '') {
alert('名称不能为空')
return;
}
// 游戏开始,隐藏开始界面
$('#home').classList.add('hidden')
play(val)
}
}).catch(console.error)
Приведенный выше код может запустить игру нормально, но игра запускается, а экрана нет.
Итак, давайте теперь разработаем код для рендеринга экрана.
ресурс загрузки
мы все знаемcanvas
Отрисовка картинки требует, чтобы картинка была загружена, иначе ничего не будет, поэтому давайте напишем код, который сначала загружает все картинки.
Файлы изображений хранятся в
public/assets
середина
// src/client/asset.js
// 需要加载的资源
const ASSET_NAMES = [
'ball.svg',
'aim.svg'
]
// 将下载好的图片文件保存起来供canvas使用
const assets = {};
// 每一张图片都是通过promise进行加载的,所有图片加载成功后,Promise.all就会结束
const downloadPromise = Promise.all(ASSET_NAMES.map(downloadAsset))
function downloadAsset(assetName){
return new Promise(resolve => {
const asset = new Image();
asset.onload = () => {
console.log(`Downloaded ${assetName}`)
assets[assetName] = asset;
resolve();
}
asset.src = `/assets/${assetName}`
})
}
export const downloadAssets = () => downloadPromise;
export const getAsset = assetName => assets[assetName]
следующий вindex.js
введен вasset.js
.
// src/client/index.js
import { downloadAssets } from './asset'
Promise.all([
connect(),
downloadAssets()
]).then(() => {
// ...
}).catch(console.error)
В настоящее время мы можем видеть этот вывод на странице.
фотографии могут идти
iconfont
или在线体验的network
илиgithub
Скачать в.
рисовать игровые объекты
мы создаем новыйrender.js
файл, в котором прописан соответствующий код чертежа.
// src/client/render.js
import { MAP_SIZE, PLAYER } from '../shared/constants'
import { getAsset } from './asset'
import { getCurrentState } from './state'
import { $ } from './util'
const cnv = $('#cnv')
const ctx = cnv.getContext('2d')
function setCanvasSize(){
cnv.width = window.innerWidth;
cnv.height = window.innerHeight;
}
// 这里将默认设置一次canvas宽高,当屏幕缩放的时候也会设置一次
setCanvasSize();
window.addEventListener('resize', setCanvasSize)
// 绘制函数
function render(){
const { me, others, bullets } = getCurrentState();
if(!me){
return;
}
}
// 这里将启动渲染函数的定时器,将其导出,我们在index.js中使用
let renderInterval = null;
export function startRendering(){
renderInterval = setInterval(render, 1000 / 60);
}
export function stopRendering(){
ctx.clearRect(0, 0, cnv.width, cnv.height)
clearInterval(renderInterval);
}
Видно, что мы ввели вышеstate.js
серединаgetCurrentState
функция, эта функция получит последний объект данных, возвращенный сервером.
// src/client/state.js
const gameUpdates = [];
export function processGameUpdate(update){
gameUpdates.push(update)
}
export function getCurrentState(){
return gameUpdates[gameUpdates.length - 1]
}
рисовать фон
Поскольку карта в игре большая, один экран не помещается, поэтому для перемещения игроку нужен эталонный объект, и здесь в качестве эталонного объекта используется градиентный круг.
// src/client/render.js
function render(){
// ...
// 绘制背景圆
renderBackground(me.x, me.y);
// 绘制一个边界
ctx.strokeStyle = 'black'
ctx.lineWidth = 1;
// 默认边界左上角在屏幕中心,减去人物的x/y算出相对于人物的偏移
ctx.strokeRect(cnv.width / 2 - me.x, cnv.height / 2 - me.y, MAP_SIZE, MAP_SIZE)
}
function renderBackground(x, y){
// 假设背景圆的位置在屏幕左上角,那么cnv.width/height / 2就会将这个圆定位在屏幕中心
// MAP_SIZE / 2 - x/y 地图中心与玩家的距离,这段距离就是背景圆圆心正确的位置
const backgroundX = MAP_SIZE / 2 - x + cnv.width / 2;
const backgroundY = MAP_SIZE / 2 - y + cnv.height / 2;
const bgGradient = ctx.createRadialGradient(
backgroundX,
backgroundY,
MAP_SIZE / 10,
backgroundX,
backgroundY,
MAP_SIZE / 2
)
bgGradient.addColorStop(0, 'rgb(100, 216, 89)')
bgGradient.addColorStop(1, 'rgb(154, 207, 223)')
ctx.fillStyle = bgGradient;
ctx.fillRect(0, 0, cnv.width, cnv.height)
}
Эффект приведенного выше кода следующий.
Позиция наших игроков задается случайными числами на сервере, поэтому каждый раз, когда вы заходите в игру, это случайная позиция.
нарисовать игрока
Следующим шагом будет рисование игрока, все еще находящегося вrender.js
Напишите соответствующий код в .
// src/client/render.js
function render(){
// ...
// 绘制所有的玩家
// 第一个参数是对照位置的数据,第二个参数是玩家渲染的数据
renderPlayer(me, me);
others.forEach(renderPlayer.bind(null, me));
}
function renderPlayer(me, player){
const { x, y } = player;
// 默认将玩家渲染在屏幕中心,然后将位置设置上去,再计算相对于自己的相对位置,就是正确在屏幕的位置了
const canvasX = cnv.width / 2 + x - me.x;
const canvasY = cnv.height / 2 + y - me.y;
ctx.save();
ctx.translate(canvasX, canvasY);
ctx.drawImage(
getAsset('ball.svg'),
-PLAYER.RADUIS,
-PLAYER.RADUIS,
PLAYER.RADUIS * 2,
PLAYER.RADUIS * 2
)
ctx.restore();
// 绘制血条背景
ctx.fillStyle = 'white'
ctx.fillRect(
canvasX - PLAYER.RADUIS,
canvasY - PLAYER.RADUIS - 8,
PLAYER.RADUIS * 2,
4
)
// 绘制血条
ctx.fillStyle = 'red'
ctx.fillRect(
canvasX - PLAYER.RADUIS,
canvasY - PLAYER.RADUIS - 8,
PLAYER.RADUIS * 2 * (player.hp / PLAYER.MAX_HP),
4
)
// 绘制玩家的名称
ctx.fillStyle = 'white'
ctx.textAlign = 'center';
ctx.font = "20px '微软雅黑'"
ctx.fillText(player.username, canvasX, canvasY - PLAYER.RADUIS - 16)
}
Это правильно нарисует игрока.
На двух картинках выше два игрока, которым я открыл две страницы для входа в игру.Видно, что они центрированы на себе, а другие игроки нарисованы относительно него.
Развитие игрового процесса
Добавить мобильное взаимодействие
Теперь, когда мы нарисовали игрока, мы можем начать его перемещать.
мы создаемinput.js
написать соответствующийвходное взаимодействиекод.
// src/client/input.js
// 发送信息给后端
import { emitControl } from "./networking";
function onKeydown(ev){
let code = ev.keyCode;
switch(code){
case 65:
emitControl({
action: 'move-left',
data: false
})
break;
case 68:
emitControl({
action: 'move-right',
data: true
})
break;
case 87:
emitControl({
action: 'move-top',
data: false
})
break;
case 83:
emitControl({
action: 'move-bottom',
data: true
})
break;
}
}
function onKeyup(ev){
let code = ev.keyCode;
switch(code){
case 65:
emitControl({
action: 'move-left',
data: 0
})
break;
case 68:
emitControl({
action: 'move-right',
data: 0
})
break;
case 87:
emitControl({
action: 'move-top',
data: 0
})
break;
case 83:
emitControl({
action: 'move-bottom',
data: 0
})
break;
}
}
export function startCapturingInput(){
window.addEventListener('keydown', onKeydown);
window.addEventListener('keyup', onKeyup);
}
export function stopCapturingInput(){
window.removeEventListener('keydown', onKeydown);
window.removeEventListener('keyup', onKeyup);
}
// src/client/networking.js
// ...
// 发送信息给后端
export const emitControl = data => {
socket.emit(Constants.MSG_TYPES.INPUT, data);
}
Приведенный выше код очень прост, судя поW
/S
/A
/D
Четыре клавиши отправляют информацию на серверную часть.
Бэкэнд выполняет обработку и передает ее объекту игрока, который затем перемещает игрока в обновлении игры.
// src/servers/core/game.js
class Game{
// ...
update(){
const now = Date.now();
const dt = (now - this.lastUpdateTime) / 1000;
this.lastUpdateTime = now;
// 每次游戏更新告诉玩家对象,你要更新了
Object.keys(this.players).map(playerID => {
const player = this.players[playerID]
player.update(dt)
})
}
handleInput(socket, item){
const player = this.players[socket.id];
if(player){
let data = item.action.split('-');
let type = data[0];
let value = data[1];
switch(type){
case 'move':
// 这里是为了防止前端发送1000/-1000这种数字,会导致玩家移动飞快
player.move[value] = typeof item.data === 'boolean'
? item.data ? 1 : -1
: 0
break;
}
}
}
}
затем вplayer.js
Добавьте соответствующий мобильный код в файл .
// src/servers/objects/player.js
class Player extends Item{
constructor(data){
super(data)
this.move = {
left: 0, right: 0,
top: 0, bottom: 0
};
// ...
}
update(dt){
// 这里的dt是每次游戏更新的时间,乘于dt将会60帧也就是一秒移动speed的值
this.x += (this.move.left + this.move.right) * this.speed * dt;
this.y += (this.move.top + this.move.bottom) * this.speed * dt;
}
// ...
}
module.exports = Player;
С помощью приведенного выше кода мы реализовали логику движения игрока, давайте посмотрим на эффект.
Видно, что мы можем улететь за карту, мы вplayer.js
Добавьте соответствующий код ограничения в .
// src/servers/objects/player.js
class Player extends Item{
// ...
update(dt){
this.x += (this.move.left + this.move.right) * this.speed * dt;
this.y += (this.move.top + this.move.bottom) * this.speed * dt;
// 在地图最大尺寸和自身位置比较时,不能大于地图最大尺寸
// 在地图开始0位置和自身位置比较时,不能小于0
this.x = Math.max(0, Math.min(Constants.MAP_SIZE, this.x))
this.y = Math.max(0, Math.min(Constants.MAP_SIZE, this.y))
}
// ...
}
module.exports = Player;
добавить пули
Теперь, когда наши персонажи могут двигаться, появился инструмент для противостояния между игроками».пуля«Это должно быть необходимо, давайте разработаем это сейчас.
Давайте сначала добавим код для отправки намерения выстрела во внешний интерфейс.
// src/client/input.js
// 这里使用atan2获取鼠标相对屏幕中心的角度
function getMouseDir(ev){
const dir = Math.atan2(ev.clientX - window.innerWidth / 2, ev.clientY - window.innerHeight / 2);
return dir;
}
// 每次鼠标移动,发送方向给后端保存
function onMousemove(ev){
if(ev.button === 0){
emitControl({
action: 'dir',
data: getMouseDir(ev)
})
}
}
// 开火
function onMousedown(ev){
if(ev.button === 0){
emitControl({
action: 'bullet',
data: true
})
}
}
// 停火
function onMouseup(ev){
if(ev.button === 0){
emitControl({
action: 'bullet',
data: false
})
}
}
export function startCapturingInput(){
window.addEventListener('mousedown', onMousedown)
window.addEventListener('mousemove', onMousemove)
window.addEventListener('mouseup', onMouseup)
}
export function stopCapturingInput(){
window.removeEventListener('mousedown', onMousedown)
window.addEventListener('mousemove', onMousemove)
window.removeEventListener('mouseup', onMouseup)
}
Затем напишите соответствующий код в бэкенде.
// src/servers/core/game.js
class Game{
// ...
update(){
// ...
// 如果子弹飞出地图或是已经达到人物身上,就过滤掉
this.bullets = this.bullets.filter(item => !item.isOver)
// 为每一个子弹更新
this.bullets.map(bullet => {
bullet.update(dt);
})
Object.keys(this.players).map(playerID => {
const player = this.players[playerID]
// 在人物对象中添加发射子弹
const bullet = player.update(dt)
if(bullet){
this.bullets.push(bullet);
}
})
}
handleInput(socket, item){
const player = this.players[socket.id];
if(player){
let data = item.action.split('-');
let type = data[0];
let value = data[1];
switch(type){
case 'move':
player.move[value] = typeof item.data === 'boolean'
? item.data ? 1 : -1
: 0
break;
// 更新鼠标位置
case 'dir':
player.fireMouseDir = item.data;
break;
// 开火/停火
case 'bullet':
player.fire = item.data;
break;
}
}
}
}
module.exports = Game;
существуетgame.js
Логика пули написана наplayer.js
возвращаетbullet
Объект может быть успешно запущен.
// src/servers/objects/player.js
const Bullet = require('./bullet');
class Player extends Item{
constructor(data){
super(data)
// ...
// 开火
this.fire = false;
this.fireMouseDir = 0;
this.fireTime = 0;
}
update(dt){
// ...
// 每帧都减少开火延迟
this.fireTime -= dt;
// 判断是否开火
if(this.fire != false){
// 如果没有延迟了就返回一个bullet对象
if(this.fireTime <= 0){
// 将延迟重新设置
this.fireTime = Constants.PLAYER.FIRE;
// 创建一个bullet对象,将自身的id传递过去,后面做碰撞的时候,就自己发射的子弹就不会打到自己
return new Bullet(this.id, this.x, this.y, this.fireMouseDir);
}
}
}
// ...
}
module.exports = Player;
соответствующийbullet.js
Файл также должен быть заполнен.
// src/servers/objects/bullet.js
const shortid = require('shortid')
const Constants = require('../../shared/constants');
const Item = require('./item')
class Bullet extends Item{
constructor(parentID, x, y, dir){
super({
id: shortid(),
x, y,
w: Constants.BULLET.RADUIS,
h: Constants.BULLET.RADUIS,
});
this.rotate = 0;
this.dir = dir;
this.parentID = parentID;
this.isOver = false;
}
update(dt){
// 使用三角函数将鼠标位置计算出对应的x/y值
this.x += dt * Constants.BULLET.SPEED * Math.sin(this.dir);
this.y += dt * Constants.BULLET.SPEED * Math.cos(this.dir);
// 这里是为了让子弹有一个旋转功能,一秒转一圈
this.rotate += dt * 360;
// 离开地图就将isOver设置为true,在game.js中就会过滤
if(this.x < 0 || this.x > Constants.MAP_SIZE
|| this.y < 0 || this.y > Constants.MAP_SIZE){
this.isOver = true;
}
}
serializeForUpdate(){
return {
...(super.serializeForUpdate()),
rotate: this.rotate
}
}
}
module.exports = Bullet;
Вот
shortid
Библиотека, которая создает случайное числоиспользовать
npm install shortid --save
Установить
В это время мы можем нормально стрелять пулями, но мы пока не можем видеть пули.
Это потому, что соответствующий код рисования не написан.
// src/client/render.js
function render(){
// ...
bullets.map(renderBullet.bind(null, me))
// ...
}
function renderBullet(me, bullet){
const { x, y, rotate } = bullet;
ctx.save();
// 偏移到子弹相对人物的位置
ctx.translate(cnv.width / 2 + x - me.x, cnv.height / 2 + y - me.y)
// 旋转
ctx.rotate(Math.PI / 180 * rotate)
// 绘制子弹
ctx.drawImage(
getAsset('bullet.svg'),
-BULLET.RADUIS,
-BULLET.RADUIS,
BULLET.RADUIS * 2,
BULLET.RADUIS * 2
)
ctx.restore();
}
В это время мы доделаем функцию стрельбы пулями.
Посмотрим на эффект.
Проверка удара
Теперь, когда логика перемещения игрока и отправки пуль завершена, пришло время разработать самое важное для боя обнаружение столкновений.
мы напрямуюgame.js
добавил в.
// src/servers/core/game.js
class Game{
// ..
update(){
// ...
// 将玩家及子弹传入进行碰撞检测
this.collisions(Object.values(this.players), this.bullets);
Object.keys(this.sockets).map(playerID => {
const socket = this.sockets[playerID]
const player = this.players[playerID]
// 如果玩家的血量低于等于0就告诉他游戏结束,并将其移除游戏
if(player.hp <= 0){
socket.emit(Constants.MSG_TYPES.GAME_OVER)
this.disconnect(socket);
}
})
// ...
}
collisions(players, bullets){
for(let i = 0; i < bullets.length; i++){
for(let j = 0; j < players.length; j++){
let bullet = bullets[i];
let player = players[j];
// 自己发射的子弹不能达到自己身上
// distanceTo是一个使用勾股定理判断物体与自己的距离,如果距离小于玩家与子弹的半径就是碰撞了
if(bullet.parentID !== player.id
&& player.distanceTo(bullet) <= Constants.PLAYER.RADUIS + Constants.BULLET.RADUIS
){
// 子弹毁灭
bullet.isOver = true;
// 玩家扣血
player.takeBulletDamage();
// 这里判断给最后一击使其死亡的玩家加分
if(player.hp <= 0){
this.players[bullet.parentID].score++;
}
break;
}
}
}
}
// ...
}
module.exports = Game;
Затем добавьте логику игры поверх логики во внешнем интерфейсе.
// src/client/index.js
// ...
import { startRendering, stopRendering } from './render'
import { startCapturingInput, stopCapturingInput } from './input'
Promise.all([
connect(gameOver),
downloadAssets()
]).then(() => {
// ...
}).catch(console.error)
function gameOver(){
// 停止渲染
stopRendering();
// 停止监听
stopCapturingInput();
// 将开始界面显示出来
$('#home').classList.remove('hidden');
alert('你GG了,重新进入游戏吧。');
}
На данный момент мы можем играть в игру в обычном режиме.
Посмотрим на эффект.
Функция таблицы лидеров
Теперь, когда мы завершили нормальную базовую работу игры, теперь нам нужен рейтинг, чтобы игроки могли получить игровой опыт (ахахаха).
Сначала мы отображаем таблицу лидеров в интерфейсе.
Сначала мы добавляем данные, которые возвращают таблицу лидеров в бэкэнд.
// src/servers/core/game.js
class Game{
// ...
createUpdate(player){
// ...
return {
// ...
leaderboard: this.getLeaderboard()
}
}
getLeaderboard(){
return Object.values(this.players)
.sort((a, b) => b.score - a.score)
.slice(0, 10)
.map(item => ({ username: item.username, score: item.score }))
}
}
module.exports = Game;
Затем напишите стиль таблицы лидеров в интерфейсе.
// src/client/html/index.html
// ..
<body>
<canvas id="cnv"></canvas>
<div class="ranking hidden">
<table>
<thead>
<tr>
<th>排名</th>
<th>姓名</th>
<th>积分</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
// ...
</body>
</html>
// src/client/css/main.css
// ...
.ranking{
position: fixed;
width: 300px;
background: #333;
top: 0;
left: 0;
color: white;
padding: 10px;
}
.ranking table{
border: 0;
border-collapse: 0;
width: 100%;
}
Затем напишите функцию для отображения данных вrender.js
середина.
// src/client/render.js
// ...
export function updateRanking(data){
let str = '';
data.map((item, i) => {
str += `
<tr>
<td>${i + 1}</td>
<td>${item.username}</td>
<td>${item.score}</td>
<tr>
`
})
$('.ranking table tbody').innerHTML = str;
}
Наконец вstate.js
используйте эту функцию.
// src/client/state.js
import { updateRanking } from "./render";
const gameUpdates = [];
export function processGameUpdate(update){
gameUpdates.push(update)
updateRanking(update.leaderboard)
}
// ...
Теперь рендеринг списков лидеров не проблема, теперьindex.js
Управляйте отображением и скрытием таблицы лидеров.
// src/client/index.js
// ...
Promise.all([
connect(gameOver),
downloadAssets()
]).then(() => {
// ...
$('#play-button').onclick = () => {
// ...
$('.ranking').classList.remove('hidden')
// ...
}
}).catch(console.error)
function gameOver(){
// ...
$('.ranking').classList.add('hidden')
// ...
}
На этом функция таблицы лидеров завершена.
разработка реквизита
Конечно, геймплей в игре сейчас очень плохой, давайте добавим несколько реквизитов, чтобы улучшить геймплей.
первыйprop.js
Совершенствуй это.
// src/servers/objects/prop.js
const Constants = require('../../shared/constants')
const Item = require('./item')
class Prop extends Item{
constructor(type){
// 随机位置
const x = (Math.random() * .5 + .25) * Constants.MAP_SIZE;
const y = (Math.random() * .5 + .25) * Constants.MAP_SIZE;
super({
x, y,
w: Constants.PROP.RADUIS,
h: Constants.PROP.RADUIS
});
this.isOver = false;
// 什么类型的buff
this.type = type;
// 持续10秒
this.time = 10;
}
// 这个道具对玩家的影响
add(player){
switch(this.type){
case 'speed':
player.speed += 500;
break;
}
}
// 移除这个道具时将对玩家的影响消除
remove(player){
switch(this.type){
case 'speed':
player.speed -= 500;
break;
}
}
// 每帧更新
update(dt){
this.time -= dt;
}
serializeForUpdate(){
return {
...(super.serializeForUpdate()),
type: this.type,
time: this.time
}
}
}
module.exports = Prop;
Тогда мыgame.js
Добавьте логику периодического добавления реквизита.
// src/servers/core/game.js
const Constants = require("../../shared/constants");
const Player = require("../objects/player");
const Prop = require("../objects/prop");
class Game{
constructor(){
// ...
// 增加一个保存道具的数组
this.props = [];
// ...
// 添加道具的计时
this.createPropTime = 0;
setInterval(this.update.bind(this), 1000 / 60);
}
update(){
// ...
// 这个定时为0时添加
this.createPropTime -= dt;
// 过滤掉已经碰撞后的道具
this.props = this.props.filter(item => !item.isOver)
// 道具大于10个时不添加
if(this.createPropTime <= 0 && this.props.length < 10){
this.createPropTime = Constants.PROP.CREATE_TIME;
this.props.push(new Prop('speed'))
}
// ...
this.collisionsBullet(Object.values(this.players), this.bullets);
this.collisionsProp(Object.values(this.players), this.props)
// ...
}
// 玩家与道具的碰撞检测
collisionsProp(players, props){
for(let i = 0; i < props.length; i++){
for(let j = 0; j < players.length; j++){
let prop = props[i];
let player = players[j];
if(player.distanceTo(prop) <= Constants.PLAYER.RADUIS + Constants.PROP.RADUIS){
// 碰撞后,道具消失
prop.isOver = true;
// 玩家添加这个道具的效果
player.pushBuff(prop);
break;
}
}
}
}
// 这里是之前的collisions,为了和碰撞道具区分
collisionsBullet(players, bullets){
// ...
}
createUpdate(player){
// ...
return {
// ...
props: this.props.map(prop => prop.serializeForUpdate())
}
}
}
module.exports = Game;
Здесь обнаружение столкновений можно оптимизировать и преобразовать в функцию столкновений, которую можно использовать в любой сцене, а здесь для удобства она сразу скопирована в две части.
следующий вplayer.js
Добавьте соответствующую функцию.
// src/servers/objects/player.js
const Item = require('./item')
const Constants = require('../../shared/constants');
const Bullet = require('./bullet');
class Player extends Item{
// ...
update(dt){
// ...
// 判断buff是否失效
this.buffs = this.buffs.filter(item => {
if(item.time > 0){
return item;
} else {
item.remove(this);
}
})
// buff的持续时间每帧都减少
this.buffs.map(buff => buff.update(dt));
// ...
}
// 添加
pushBuff(prop){
this.buffs.push(prop);
prop.add(this);
}
// ...
serializeForUpdate(){
return {
// ...
buffs: this.buffs.map(item => item.serializeForUpdate())
}
}
}
module.exports = Player;
Функция, необходимая в задней части, была завершена, и теперь вы добавите код рисования на передний конец.
// src/client/render.js
// ...
function render(){
const { me, others, bullets, props } = getCurrentState();
if(!me){
return;
}
// ...
// 绘制道具
props.map(renderProp.bind(null, me))
// ...
}
// ...
// 绘制道具
function renderProp(me, prop){
const { x, y, type } = prop;
ctx.save();
ctx.drawImage(
getAsset(`${type}.svg`),
cnv.width / 2 + x - me.x,
cnv.height / 2 + y - me.y,
PROP.RADUIS * 2,
PROP.RADUIS * 2
)
ctx.restore();
}
function renderPlayer(me, player){
// ...
// 显示玩家已经领取到的道具
player.buffs.map((buff, i) => {
ctx.drawImage(
getAsset(`${buff.type}.svg`),
canvasX - PLAYER.RADUIS + i * 22,
canvasY + PLAYER.RADUIS + 16,
20, 20
)
})
}
На этом ускорение опоры завершено.
Если вам нужно добавить больше реквизита, вы можетеprop.js
добавляется в , а вgame.js
При создании реквизита вspeed
изменено на случайноеtype
.
Готовый эффект.
отключить дисплей
Мы можем написать интерфейс специально для отображения запроса на отключение.
// src/client/html/index.html
// ...
<body>
// ...
<div class="disconnect hidden">
<p>与服务器断开连接了</p>
</div>
</body>
// src/client/css/main.css
.disconnect{
position: fixed;
width: 100%;
height: 100vh;
left: 0;
top: 0;
z-index: 100;
background: white;
display: flex;
justify-content: center;
align-items: center;
color: #444;
font-size: 40px;
}
опять такиnetworking.js
Этот интерфейс отображается при разрыве соединения.
// src/client/networking.js
// ...
export const connect = onGameOver => {
connectPromise.then(() => {
// ...
socket.on('disconnect', () => {
$('.disconnect').classList.remove('hidden')
console.log('Disconnected from server.')
})
})
}
// ...
В это время мы открываем игру, а затем закрываем игровой сервис, игра будет отображать этот интерфейс.
конец
Написание здесь, эта статья заканчивается.
Спасибо за просмотр, если вы считаете, что текст хороший, вы можете поставить лайк и поддержать (хе-хе).