2015 онд гарсан
Хэрэв та эдгээр тоглоомуудын талаар хэзээ ч сонсож байгаагүй бол эдгээр нь тоглоход хялбар олон тоглогчтой үнэгүй вэб тоглоомууд юм (бүртгэл шаардлагагүй). Тэд ихэвчлэн нэг талбарт олон тооны өрсөлдөгчидтэй тулгардаг. Бусад алдартай .io тоглоомууд:
Энэ нийтлэлд бид хэрхэн хийхийг судлах болно .io тоглоомыг эхнээс нь үүсгэх. Үүний тулд зөвхөн Javascript-ийн мэдлэг хангалттай байх болно: та синтакс гэх мэт зүйлсийг ойлгох хэрэгтэй this
и
.io тоглоомын жишээ
Сурах тусламжийн хувьд бид лавлана
Тоглоом нь маш энгийн: та бусад тоглогчид байдаг талбайд хөлөг онгоцыг удирддаг. Таны хөлөг онгоц автоматаар сум харвадаг бөгөөд та бусад тоглогчдыг сумнаас нь зайлсхийж цохихыг оролддог.
1. Төслийн товч тойм / бүтэц
Би санал болгож байна
эх кодыг татаж авах Та намайг дагах боломжтой тоглоомын жишээ.
Жишээ нь дараахь зүйлийг ашигладаг.
илэрхийл тоглоомын вэб серверийг удирддаг хамгийн алдартай Node.js вэб фреймворк юм.socket.io - хөтөч болон сервер хооронд өгөгдөл солилцох websocket номын сан.Вэб хуудас - модулийн менежер. Та яагаад Webpack ашиглах талаар уншиж болно.энд .
Төслийн лавлах бүтэц нь дараах байдалтай байна.
public/
assets/
...
src/
client/
css/
...
html/
index.html
index.js
...
server/
server.js
...
shared/
constants.js
үзэгчид/
Бүх зүйл хавтасанд байна public/
серверээс статик байдлаар илгээгдэх болно. IN public/assets/
Манай төслийн ашигласан зургуудыг агуулна.
SRC /
Бүх эх код хавтас дотор байна src/
. Гарчиг client/
и server/
өөрсдийнхөө төлөө ярих ба shared/
Үйлчлүүлэгч болон серверийн аль алиных нь импортолсон тогтмол файлыг агуулдаг.
2. Ассемблей/төслийн тохиргоо
Дээр дурдсанчлан бид төслийг бүтээхдээ модулийн менежерийг ашигладаг.
webpack.common.js:
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
entry: {
game: './src/client/index.js',
},
output: {
filename: '[name].[contenthash].js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: /.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
options: {
presets: ['@babel/preset-env'],
},
},
},
{
test: /.css$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
},
'css-loader',
],
},
],
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].[contenthash].css',
}),
new HtmlWebpackPlugin({
filename: 'index.html',
template: 'src/client/html/index.html',
}),
],
};
Энд байгаа хамгийн чухал мөрүүд нь:
src/client/index.js
Javascript (JS) үйлчлүүлэгчийн нэвтрэх цэг юм. Webpack эндээс эхэлж, бусад импортын файлуудыг рекурсив байдлаар хайх болно.- Манай Webpack-ийн гаралтын JS нь лавлахад байрлана
dist/
. Би энэ файлыг манайх гэж нэрлэх болно js багц. - Бидний хэрэглэдэг
Babel , ялангуяа тохиргоо@babel/preset-env Манай JS кодыг хуучин хөтчүүдэд шилжүүлэх. - Бид JS файлуудын эш татсан бүх CSS-г задалж, нэг газар нэгтгэхийн тулд залгаасыг ашиглаж байна. Би түүнийг манайх гэж дуудна css багц.
Та хачирхалтай багц файлын нэрийг анзаарсан байх '[name].[contenthash].ext'
. Тэд агуулдаг [name]
оролтын цэгийн нэрээр солигдох болно (бидний тохиолдолд энэ game
), ба [contenthash]
файлын агуулгын хэшээр солигдох болно. Бид үүнийг хийдэг contenthash
). Эцсийн үр дүн нь харах файлын нэр байх болно game.dbeee76e91a97d0c7207.js
.
файл webpack.common.js
нь бидний хөгжүүлэлт болон дууссан төслийн тохиргоонд импортлох үндсэн тохиргооны файл юм. Энд жишээ хөгжүүлэлтийн тохиргоо байна:
webpack.dev.js
const merge = require('webpack-merge');
const common = require('./webpack.common.js');
module.exports = merge(common, {
mode: 'development',
});
Үр ашгийн хувьд бид хөгжлийн явцад ашигладаг webpack.dev.js
, мөн рүү шилждэг webpack.prod.js
үйлдвэрлэлд нэвтрүүлэх үед багцын хэмжээг оновчтой болгох.
Орон нутгийн тохиргоо
Би уг төслийг локал машин дээр суулгахыг зөвлөж байна, ингэснээр та энэ нийтлэлд жагсаасан алхмуудыг дагаж болно. Тохируулга нь энгийн: эхлээд систем суулгасан байх ёстой
$ git clone https://github.com/vzhou842/example-.io-game.git
$ cd example-.io-game
$ npm install
мөн та явахад бэлэн байна! Хөгжүүлэгч серверийг эхлүүлэхийн тулд зүгээр л ажиллуулна уу
$ npm run develop
вэб хөтөч рүү очно уу
3. Үйлчлүүлэгчийн нэвтрэх цэгүүд
Тоглоомын код руугаа орцгооё. Эхлээд бидэнд хуудас хэрэгтэй index.html
, сайтад зочлох үед хөтөч эхлээд ачаалах болно. Манай хуудас маш энгийн байх болно:
index.html
Жишээ нь io тоглоом ТОГЛОХ
Энэ кодын жишээг ойлгомжтой болгох үүднээс бага зэрэг хялбаршуулсан бөгөөд би бусад олон нийтлэлийн жишээн дээр ижил зүйлийг хийх болно. Бүрэн кодыг үргэлж эндээс үзэх боломжтой
Бидэнд байгаа:
HTML5 зураг элемент (<canvas>
) үүнийг бид тоглоомыг үзүүлэхэд ашиглах болно.<link>
Манай CSS багцыг нэмэх.<script>
Манай Javascript багцыг нэмэх.- Хэрэглэгчийн нэр бүхий үндсэн цэс
<input>
болон PLAY товчлуур (<button>
).
Нүүр хуудсыг ачаалсны дараа хөтөч JS файлын оролтын цэгээс эхлэн Javascript кодыг ажиллуулж эхэлнэ. src/client/index.js
.
index.js
import { connect, play } from './networking';
import { startRendering, stopRendering } from './render';
import { startCapturingInput, stopCapturingInput } from './input';
import { downloadAssets } from './assets';
import { initState } from './state';
import { setLeaderboardHidden } from './leaderboard';
import './css/main.css';
const playMenu = document.getElementById('play-menu');
const playButton = document.getElementById('play-button');
const usernameInput = document.getElementById('username-input');
Promise.all([
connect(),
downloadAssets(),
]).then(() => {
playMenu.classList.remove('hidden');
usernameInput.focus();
playButton.onclick = () => {
// Play!
play(usernameInput.value);
playMenu.classList.add('hidden');
initState();
startCapturingInput();
startRendering();
setLeaderboardHidden(false);
};
});
Энэ нь төвөгтэй мэт санагдаж болох ч энд тийм ч их зүйл алга:
- Бусад хэд хэдэн JS файлыг импортлож байна.
- CSS импорт (Тиймээс Webpack тэдгээрийг манай CSS багцад оруулахаа мэддэг).
- Ажиллуулах
connect()
сервертэй холбоо тогтоож ажиллуулнаdownloadAssets()
тоглоомыг үзүүлэхэд шаардлагатай зургийг татаж авах. - 3-р үе шат дууссаны дараа үндсэн цэс гарч ирнэ (
playMenu
). - "PLAY" товчийг дарахад тохируулагчийг тохируулна уу. Товчлуур дээр дарахад код нь тоглоомыг эхлүүлж, серверт бид тоглоход бэлэн байна гэж хэлдэг.
Манай клиент-серверийн логикийн гол "мах" нь файлаар импортлогдсон файлуудад байдаг index.js
. Одоо бид бүгдийг дарааллаар нь авч үзэх болно.
4. Хэрэглэгчийн мэдээлэл солилцох
Энэ тоглоомонд бид сервертэй харилцахдаа алдартай номын санг ашигладаг
Бидэнд нэг файл байх болно src/client/networking.js
хэн халамжлах вэ хүн бүр сервертэй харилцах:
networking.js
import io from 'socket.io-client';
import { processGameUpdate } from './state';
const Constants = require('../shared/constants');
const socket = io(`ws://${window.location.host}`);
const connectedPromise = new Promise(resolve => {
socket.on('connect', () => {
console.log('Connected to server!');
resolve();
});
});
export const connect = onGameOver => (
connectedPromise.then(() => {
// Register callbacks
socket.on(Constants.MSG_TYPES.GAME_UPDATE, processGameUpdate);
socket.on(Constants.MSG_TYPES.GAME_OVER, onGameOver);
})
);
export const play = username => {
socket.emit(Constants.MSG_TYPES.JOIN_GAME, username);
};
export const updateDirection = dir => {
socket.emit(Constants.MSG_TYPES.INPUT, dir);
};
Энэ кодыг мөн тодорхой болгох үүднээс бага зэрэг богиносгосон.
Энэ файлд гурван үндсэн үйлдэл байна:
- Бид серверт холбогдохыг оролдож байна.
connectedPromise
зөвхөн холболт тогтоосон үед л зөвшөөрнө. - Хэрэв холболт амжилттай бол бид буцаан залгах функцийг бүртгэдэг (
processGameUpdate()
иonGameOver()
) серверээс хүлээн авах мессежүүдийн хувьд. - Бид экспортолдог
play()
иupdateDirection()
Ингэснээр бусад файлууд тэдгээрийг ашиглах боломжтой болно.
5. Client Rendering
Дэлгэц дээр зургийг харуулах цаг боллоо!
…гэхдээ үүнийг хийхээс өмнө бид үүнд шаардлагатай бүх зургийг (нөөцийг) татаж авах хэрэгтэй. Нөөцийн менежер бичье:
assets.js
const ASSET_NAMES = ['ship.svg', 'bullet.svg'];
const assets = {};
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];
Нөөцийн менежментийг хэрэгжүүлэх нь тийм ч хэцүү биш юм! Гол санаа нь объектыг хадгалах явдал юм assets
, энэ нь файлын нэрний түлхүүрийг объектын утгатай холбох болно Image
. Нөөцийг ачаалах үед бид үүнийг объектод хадгалдаг assets
ирээдүйд хурдан нэвтрэхийн тулд. Хувь хүний нөөц бүрийг хэзээ татаж авахыг зөвшөөрөх вэ (өөрөөр хэлбэл, бүх нөөц), бид зөвшөөрдөг downloadPromise
.
Нөөцүүдийг татаж авсны дараа та дүрсэлж эхлэх боломжтой. Өмнө дурьдсанчлан, бид вэб хуудсан дээр зурахдаа ашигладаг <canvas>
). Бидний тоглоом маш энгийн тул бид дараах зүйлийг зурахад л хангалттай.
- Оршил
- Тоглогчийн хөлөг онгоц
- Тоглоомын бусад тоглогчид
- хясаа
Энд чухал хэсгүүд байна src/client/render.js
, энэ нь дээр дурдсан яг дөрвөн зүйлийг харуулж байна:
render.js
import { getAsset } from './assets';
import { getCurrentState } from './state';
const Constants = require('../shared/constants');
const { PLAYER_RADIUS, PLAYER_MAX_HP, BULLET_RADIUS, MAP_SIZE } = Constants;
// Get the canvas graphics context
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');
// Make the canvas fullscreen
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
function render() {
const { me, others, bullets } = getCurrentState();
if (!me) {
return;
}
// Draw background
renderBackground(me.x, me.y);
// Draw all bullets
bullets.forEach(renderBullet.bind(null, me));
// Draw all players
renderPlayer(me, me);
others.forEach(renderPlayer.bind(null, me));
}
// ... Helper functions here excluded
let renderInterval = null;
export function startRendering() {
renderInterval = setInterval(render, 1000 / 60);
}
export function stopRendering() {
clearInterval(renderInterval);
}
Энэ кодыг мөн тодорхой болгох үүднээс товчилсон болно.
render()
нь энэ файлын үндсэн функц юм. startRendering()
и stopRendering()
60 FPS дээр үзүүлэх давталтыг идэвхжүүлэхийг хянах.
Туслах туслах функцүүдийн бие даасан хэрэгжилт (жишээ нь: renderBullet()
) тийм ч чухал биш, гэхдээ энд нэг энгийн жишээ байна:
render.js
function renderBullet(me, bullet) {
const { x, y } = bullet;
context.drawImage(
getAsset('bullet.svg'),
canvas.width / 2 + x - me.x - BULLET_RADIUS,
canvas.height / 2 + y - me.y - BULLET_RADIUS,
BULLET_RADIUS * 2,
BULLET_RADIUS * 2,
);
}
Бид энэ аргыг хэрэглэж байгааг анхаарна уу getAsset()
, өмнө нь харж байсан asset.js
!
Хэрэв та бусад рендерийн туслахуудыг судлах сонирхолтой байгаа бол үлдсэн хэсгийг нь уншина уу
src/client/render.js .
6. Үйлчлүүлэгчийн оролт
Тоглоом хийх цаг болжээ тоглох боломжтой! Хяналтын схем нь маш энгийн байх болно: хөдөлгөөний чиглэлийг өөрчлөхийн тулд та хулгана (компьютер дээр) эсвэл дэлгэц дээр (хөдөлгөөнт төхөөрөмж дээр) хүрч болно. Үүнийг хэрэгжүүлэхийн тулд бид бүртгүүлнэ
Энэ бүхэнд санаа тавих болно src/client/input.js
:
input.js
import { updateDirection } from './networking';
function onMouseInput(e) {
handleInput(e.clientX, e.clientY);
}
function onTouchInput(e) {
const touch = e.touches[0];
handleInput(touch.clientX, touch.clientY);
}
function handleInput(x, y) {
const dir = Math.atan2(x - window.innerWidth / 2, window.innerHeight / 2 - y);
updateDirection(dir);
}
export function startCapturingInput() {
window.addEventListener('mousemove', onMouseInput);
window.addEventListener('touchmove', onTouchInput);
}
export function stopCapturingInput() {
window.removeEventListener('mousemove', onMouseInput);
window.removeEventListener('touchmove', onTouchInput);
}
onMouseInput()
и onTouchInput()
дууддаг Үйл явдлын сонсогчид юм updateDirection()
(аас.) networking.js
) оролтын үйл явдал тохиолдох үед (жишээлбэл, хулганыг хөдөлгөх үед). updateDirection()
оролтын үйл явдлыг зохицуулж, үүний дагуу тоглоомын төлөвийг шинэчилдэг сервертэй мессеж илгээдэг.
7. Үйлчлүүлэгчийн статус
Энэ хэсэг нь нийтлэлийн эхний хэсэгт хамгийн хэцүү байдаг. Хэрэв та үүнийг анх уншихдаа ойлгоогүй бол сэтгэлээр унах хэрэггүй! Та үүнийг алгасаад дараа нь эргэж ирж болно.
Үйлчлүүлэгч/серверийн кодыг бөглөхөд шаардлагатай оньсогоны сүүлчийн хэсэг төлөв. Client Rendering хэсгийн кодын хэсгийг санаж байна уу?
render.js
import { getCurrentState } from './state';
function render() {
const { me, others, bullets } = getCurrentState();
// Do the rendering
// ...
}
getCurrentState()
Үйлчлүүлэгчийн тоглоомын өнөөгийн байдлыг бидэнд өгөх боломжтой байх ёстой цаг хугацааны аль ч үед серверээс хүлээн авсан шинэчлэлтүүд дээр үндэслэсэн. Серверийн илгээж болох тоглоомын шинэчлэлтийн жишээ энд байна:
{
"t": 1555960373725,
"me": {
"x": 2213.8050880413657,
"y": 1469.370893425012,
"direction": 1.3082443894581433,
"id": "AhzgAtklgo2FJvwWAADO",
"hp": 100
},
"others": [],
"bullets": [
{
"id": "RUJfJ8Y18n",
"x": 2354.029197099604,
"y": 1431.6848318262666
},
{
"id": "ctg5rht5s",
"x": 2260.546457727445,
"y": 1456.8088728920968
}
],
"leaderboard": [
{
"username": "Player",
"score": 3
}
]
}
Тоглоомын шинэчлэлт бүр таван ижил талбарыг агуулна:
- t: Энэ шинэчлэлтийг хэзээ үүсгэснийг харуулсан серверийн цагийн тэмдэг.
- me: Энэ шинэчлэлтийг хүлээн авч буй тоглогчийн талаарх мэдээлэл.
- бусад: Нэг тоглоомонд оролцож буй бусад тоглогчдын талаарх мэдээллийн багц.
- сумнууд: тоглоомын сумны тухай мэдээллийн массив.
- тэргүүлэгчдийн самбар: Одоогийн тэргүүлэгчдийн самбарын өгөгдөл. Энэ нийтлэлд бид тэдгээрийг авч үзэхгүй.
7.1 Гэнэн үйлчлүүлэгчийн төлөв
Гэнэн хэрэгжилт getCurrentState()
зөвхөн хамгийн сүүлд хүлээн авсан тоглоомын шинэчлэлийн өгөгдлийг шууд буцааж өгөх боломжтой.
naive-state.js
let lastGameUpdate = null;
// Handle a newly received game update.
export function processGameUpdate(update) {
lastGameUpdate = update;
}
export function getCurrentState() {
return lastGameUpdate;
}
Сайхан бөгөөд ойлгомжтой! Гэхдээ ийм энгийн байсан бол. Энэхүү хэрэгжилт нь асуудалтай байгаа шалтгаануудын нэг нь: Энэ нь үзүүлэх фрэймийн хурдыг серверийн цагийн хурдаар хязгаарладаг.
Frame Rate: фрэймийн тоо (жишээ нь дуудлага
render()
секундэд ) эсвэл FPS. Тоглоомууд ихэвчлэн дор хаяж 60 FPS хүрэхийг хичээдэг.
Шалгалтын ханш: Серверээс үйлчлүүлэгчид тоглоомын шинэчлэлтийг илгээх давтамж. Энэ нь ихэвчлэн фрэймийн хурдаас бага байдаг. Манай тоглоомонд сервер секундэд 30 циклийн давтамжтайгаар ажилладаг.
Хэрэв бид тоглоомын хамгийн сүүлийн үеийн шинэчлэлтийг хийвэл FPS нь үндсэндээ 30-аас дээш гарахгүй Бид серверээс секундэд 30-аас илүү шинэчлэлт авдаггүй. Бид залгасан ч гэсэн render()
Секундэд 60 удаа, дараа нь эдгээр дуудлагын тал нь ижил зүйлийг дахин зурж, үндсэндээ юу ч хийхгүй. Гэнэн хэрэгжүүлэлтийн өөр нэг асуудал бол энэ юм саатал гарах хандлагатай. Интернэтийн хамгийн тохиромжтой хурдтай бол үйлчлүүлэгч яг 33 мс тутамд (секундэд 30) тоглоомын шинэчлэлтийг хүлээн авах болно:
Харамсалтай нь төгс зүйл гэж байдаггүй. Илүү бодитой зураг байх болно:
Гэнэн хэрэгжүүлэлт нь хоцролттой холбоотой бараг хамгийн муу тохиолдол юм. Хэрэв тоглоомын шинэчлэлтийг 50 мс-ийн сааталтайгаар хүлээн авбал үйлчлүүлэгчийн лангуу Энэ нь өмнөх шинэчлэлтээс тоглоомын төлөвийг харуулсан хэвээр байгаа тул нэмэлт 50 мс. Тоглогчийн хувьд энэ нь ямар эвгүй байгааг та төсөөлж болно: дур мэдэн тоормослох нь тоглоомыг хурцадмал, тогтворгүй болгоно.
7.2 Үйлчлүүлэгчийн төлөв байдал сайжирсан
Бид гэнэн хэрэглэгдэхүүнд зарим нэг сайжруулалт хийх болно. Нэгдүгээрт, бид ашигладаг үзүүлэх саатал 100 мс. Энэ нь үйлчлүүлэгчийн "одоогийн" төлөв нь сервер дээрх тоглоомын төлөвөөс үргэлж 100 мс хоцрох болно гэсэн үг юм. Жишээлбэл, сервер дээрх цаг бол 150, дараа нь үйлчлүүлэгч тухайн үед сервер байсан төлөвийг үзүүлэх болно 50:
Энэ нь тоглоомын шинэчлэлтийн урьдчилан таамаглах боломжгүй цагийг даван туулахын тулд 100 мс буфер өгдөг:
Үүний үр өгөөж нь байнгын байх болно
Бид бас нэртэй өөр техникийг ашиглаж болно
үйлчлүүлэгч талын таамаглал , энэ нь хүлээгдэж буй хоцролтыг багасгах сайн ажил хийдэг боловч энэ нийтлэлд тусгагдаагүй болно.
Бидний ашиглаж байгаа өөр нэг сайжруулалт шугаман интерполяци. Үзүүлэлтийн хоцрогдлын улмаас бид ихэвчлэн үйлчлүүлэгчийн одоогийн цаг хугацаанаас дор хаяж нэг удаа шинэчлэгдсэн байдаг. Дуудсан үед getCurrentState()
, бид гүйцэтгэж чадна
Энэ нь фрэймийн хурдтай холбоотой асуудлыг шийддэг: бид одоо хүссэн фрэймийн хурдаараа өвөрмөц фрэймүүдийг үзүүлэх боломжтой боллоо!
7.3 Сайжруулсан үйлчлүүлэгчийн төлөвийг хэрэгжүүлэх
Хэрэгжүүлэх жишээ нь src/client/state.js
render lag болон шугаман интерполяцыг хоёуланг нь ашигладаг боловч удаан биш. Кодоо хоёр хэсэгт хувааж үзье. Энд эхнийх нь:
state.js 1-р хэсэг
const RENDER_DELAY = 100;
const gameUpdates = [];
let gameStart = 0;
let firstServerTimestamp = 0;
export function initState() {
gameStart = 0;
firstServerTimestamp = 0;
}
export function processGameUpdate(update) {
if (!firstServerTimestamp) {
firstServerTimestamp = update.t;
gameStart = Date.now();
}
gameUpdates.push(update);
// Keep only one game update before the current server time
const base = getBaseUpdate();
if (base > 0) {
gameUpdates.splice(0, base);
}
}
function currentServerTime() {
return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY;
}
// Returns the index of the base update, the first game update before
// current server time, or -1 if N/A.
function getBaseUpdate() {
const serverTime = currentServerTime();
for (let i = gameUpdates.length - 1; i >= 0; i--) {
if (gameUpdates[i].t <= serverTime) {
return i;
}
}
return -1;
}
Эхний алхам бол юу болохыг олж мэдэх явдал юм currentServerTime()
. Бидний өмнө нь харсанчлан тоглоомын шинэчлэлт бүр серверийн цагийн тэмдэгтэй байдаг. Бид серверийн ард 100 мс-ийн зайд зургийг гаргахын тулд үзүүлэх хоцролтыг ашиглахыг хүсч байна, гэхдээ Бид сервер дээрх одоогийн цагийг хэзээ ч мэдэхгүй, учир нь бид ямар ч шинэчлэлтийг авах хүртэл хэр удсаныг мэдэхгүй. Интернет нь урьдчилан таамаглах аргагүй бөгөөд хурд нь маш өөр байж болно!
Энэ асуудлыг тойрч гарахын тулд бид боломжийн ойролцооллыг ашиглаж болно: бид Эхний шинэчлэлт шууд ирсэн мэт дүр эсгэх. Хэрэв энэ үнэн байсан бол бид яг энэ мөчид серверийн цагийг мэдэх байсан! Бид серверийн цагийн тэмдгийг хадгалдаг firstServerTimestamp
мөн бидний орон нутгийн (үйлчлүүлэгч) тухайн агшинд цагийн тэмдэг gameStart
.
Хүлээгээрэй. Энэ нь серверийн цаг = үйлчлүүлэгчийн цаг байх ёстой биш үү? Бид яагаад "серверийн цагийн тэмдэг" болон "үйлчлүүлэгчийн цагийн тэмдэг"-ийг ялгадаг вэ? Энэ бол гайхалтай асуулт! Энэ нь тэд ижил зүйл биш юм. Date.now()
үйлчлүүлэгч болон серверт өөр өөр цагийн тэмдэг буцаах бөгөөд энэ нь эдгээр машинуудын локал хүчин зүйлээс хамаарна. Цагийн тэмдэг нь бүх машин дээр ижил байна гэж хэзээ ч бүү бод.
Одоо бид юу болохыг ойлгож байна currentServerTime()
: буцаж ирнэ одоогийн үзүүлэх цагийн серверийн цагийн тэмдэг. Өөрөөр хэлбэл, энэ нь серверийн одоогийн цаг (firstServerTimestamp <+ (Date.now() - gameStart)
) үзүүлэх саатлыг хасах (RENDER_DELAY
).
Одоо бид тоглоомын шинэчлэлтийг хэрхэн зохицуулж байгааг харцгаая. Шинэчлэлтийн серверээс хүлээн авах үед үүнийг дууддаг processGameUpdate()
мөн бид шинэ шинэчлэлтийг массив болгон хадгалдаг gameUpdates
. Дараа нь санах ойн ашиглалтыг шалгахын тулд бид өмнөх бүх хуучин шинэчлэлтүүдийг устгадаг суурь шинэчлэлУчир нь тэд бидэнд хэрэггүй болсон.
"Үндсэн шинэчлэл" гэж юу вэ? Энэ серверийн одоогийн цагийг буцаах замаар бидний олсон анхны шинэчлэлт. Энэ диаграмыг санаж байна уу?
"Client Render Time"-ын зүүн талд байгаа тоглоомын шинэчлэлт нь үндсэн шинэчлэлт юм.
Үндсэн шинэчлэлтийг юунд ашигладаг вэ? Бид яагаад шинэчлэлтүүдийг үндсэн түвшинд буулгаж болох вэ? Үүнийг ойлгохын тулд үзье эцэст нь хэрэгжилтийг авч үзэх getCurrentState()
:
state.js 2-р хэсэг
export function getCurrentState() {
if (!firstServerTimestamp) {
return {};
}
const base = getBaseUpdate();
const serverTime = currentServerTime();
// If base is the most recent update we have, use its state.
// Else, interpolate between its state and the state of (base + 1).
if (base < 0) {
return gameUpdates[gameUpdates.length - 1];
} else if (base === gameUpdates.length - 1) {
return gameUpdates[base];
} else {
const baseUpdate = gameUpdates[base];
const next = gameUpdates[base + 1];
const r = (serverTime - baseUpdate.t) / (next.t - baseUpdate.t);
return {
me: interpolateObject(baseUpdate.me, next.me, r),
others: interpolateObjectArray(baseUpdate.others, next.others, r),
bullets: interpolateObjectArray(baseUpdate.bullets, next.bullets, r),
};
}
}
Бид гурван хэргийг хянана:
base < 0
Одоогийн үзүүлэх хугацаа хүртэл шинэчлэлт байхгүй гэсэн үг (дээрх хэрэгжүүлэлтийг үзнэ үүgetBaseUpdate()
). Энэ нь тоглолтын эхэн үед рендерийн хоцрогдолтойгоор тохиолдож болно. Энэ тохиолдолд бид хамгийн сүүлийн үеийн шинэчлэлтийг ашигладаг.base
нь бидний хамгийн сүүлийн үеийн шинэчлэлт юм. Энэ нь сүлжээний саатал эсвэл интернетийн холболт муутай холбоотой байж болох юм. Энэ тохиолдолд бид хамгийн сүүлийн үеийн шинэчлэлтийг ашиглаж байна.- Бидэнд одоогийн үзүүлэх хугацаанаас өмнө болон дараа шинэчлэлт байгаа тул боломжтой интерполяци хийх!
Үлдсэн бүх зүйл state.js
нь энгийн (гэхдээ уйтгартай) математикийн шугаман интерполяцийн хэрэгжилт юм. Хэрэв та өөрөө үүнийг судлахыг хүсч байвал нээнэ үү state.js
тухай
2-р хэсэг. Backend сервер
Энэ хэсэгт бид бидний удирддаг Node.js backend-ийг авч үзэх болно
1. Серверийн нэвтрэх цэг
Вэб серверийг удирдахын тулд бид Node.js нэртэй алдартай вэб хүрээг ашиглах болно src/server/server.js
:
server.js 1-р хэсэг
const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackConfig = require('../../webpack.dev.js');
// Setup an Express server
const app = express();
app.use(express.static('public'));
if (process.env.NODE_ENV === 'development') {
// Setup Webpack for development
const compiler = webpack(webpackConfig);
app.use(webpackDevMiddleware(compiler));
} else {
// Static serve the dist/ folder in production
app.use(express.static('dist'));
}
// Listen on port
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Server listening on port ${port}`);
Эхний хэсэгт бид Webpack-ийн талаар ярилцсаныг санаж байна уу? Энд бид өөрсдийн Webpack тохиргоог ашиглах болно. Бид тэдгээрийг хоёр аргаар ашиглах болно:
- Хэрэглэх
webpack-dev-middleware манай хөгжлийн багцуудыг автоматаар дахин бүтээх, эсвэл - фолдерыг статик байдлаар шилжүүлэх
dist/
, вэбпак нь үйлдвэрлэлийн бүтээцийн дараа бидний файлуудыг бичих болно.
Өөр нэг чухал ажил server.js
серверийг тохируулах явдал юм
server.js 2-р хэсэг
const socketio = require('socket.io');
const Constants = require('../shared/constants');
// Setup Express
// ...
const server = app.listen(port);
console.log(`Server listening on port ${port}`);
// Setup socket.io
const io = socketio(server);
// Listen for socket.io connections
io.on('connection', socket => {
console.log('Player connected!', socket.id);
socket.on(Constants.MSG_TYPES.JOIN_GAME, joinGame);
socket.on(Constants.MSG_TYPES.INPUT, handleInput);
socket.on('disconnect', onDisconnect);
});
Сервертэй socket.io холболтыг амжилттай байгуулсны дараа бид шинэ залгуурт үйл явдал зохицуулагчийг тохируулсан. Үйл явдал зохицуулагчид үйлчлүүлэгчдээс хүлээн авсан мессежийг синглтон объект руу шилжүүлэх замаар зохицуулдаг game
:
server.js 3-р хэсэг
const Game = require('./game');
// ...
// Setup the Game
const game = new Game();
function joinGame(username) {
game.addPlayer(this, username);
}
function handleInput(dir) {
game.handleInput(this, dir);
}
function onDisconnect() {
game.removePlayer(this);
}
Бид .io тоглоом бүтээж байгаа тул бидэнд зөвхөн нэг хуулбар хэрэгтэй Game
("Тоглоом") - бүх тоглогчид нэг талбарт тоглодог! Дараагийн хэсэгт бид энэ анги хэрхэн ажилладагийг харах болно. Game
.
2. Тоглоомын серверүүд
Класс Game
сервер талын хамгийн чухал логикийг агуулсан. Энэ нь хоёр үндсэн ажилтай: тоглогчийн удирдлага и тоглоомын симуляци.
Эхний даалгавар болох тоглогчийн менежментээс эхэлье.
game.js 1-р хэсэг
const Constants = require('../shared/constants');
const Player = require('./player');
class Game {
constructor() {
this.sockets = {};
this.players = {};
this.bullets = [];
this.lastUpdateTime = Date.now();
this.shouldSendUpdate = false;
setInterval(this.update.bind(this), 1000 / 60);
}
addPlayer(socket, username) {
this.sockets[socket.id] = socket;
// Generate a position to start this player at.
const x = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5);
const y = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5);
this.players[socket.id] = new Player(socket.id, username, x, y);
}
removePlayer(socket) {
delete this.sockets[socket.id];
delete this.players[socket.id];
}
handleInput(socket, dir) {
if (this.players[socket.id]) {
this.players[socket.id].setDirection(dir);
}
}
// ...
}
Энэ тоглоомонд бид тоглогчдыг талбайгаар нь тодорхойлох болно id
Тэдний socket.io залгуур (хэрэв та андуурвал буцаж очно уу server.js
). Socket.io өөрөө сокет бүрт өвөрмөц оноодог id
тиймээс бид үүнд санаа зовох хэрэггүй. Би түүнийг дуудна Тоглогчийн ID.
Үүнийг харгалзан анги дахь хувьсагч хувьсагчдыг судалцгаая Game
:
sockets
нь тоглогчийн ID-г тоглогчтой холбоотой залгуурт холбодог объект юм. Энэ нь бидэнд тоглуулагчийн ID-аар нь залгуурт нэвтрэх боломжийг олгодог.players
нь тоглогчийн ID-г код>Тоглогчийн объекттой холбодог объект юм
bullets
объектуудын массив юм Bullet
, ямар ч тодорхой дараалал байхгүй.
lastUpdateTime
Энэ нь тоглоомыг хамгийн сүүлд шинэчилсэн цагийн тэмдэг юм. Үүнийг хэрхэн ашиглахыг бид удахгүй харах болно.
shouldSendUpdate
нь туслах хувьсагч юм. Бид удахгүй түүний хэрэглээг харах болно.
Арга зүй addPlayer()
, removePlayer()
и handleInput()
тайлбарлах шаардлагагүй, тэдгээрийг ашигладаг server.js
. Хэрэв та ой санамжаа сэргээх шаардлагатай бол арай өндөрт буцаж оч.
Сүүлийн мөр constructor()
эхэлнэ шинэчлэх мөчлөг тоглоомууд (60 шинэчлэлт / с давтамжтай):
game.js 2-р хэсэг
const Constants = require('../shared/constants');
const applyCollisions = require('./collisions');
class Game {
// ...
update() {
// Calculate time elapsed
const now = Date.now();
const dt = (now - this.lastUpdateTime) / 1000;
this.lastUpdateTime = now;
// Update each bullet
const bulletsToRemove = [];
this.bullets.forEach(bullet => {
if (bullet.update(dt)) {
// Destroy this bullet
bulletsToRemove.push(bullet);
}
});
this.bullets = this.bullets.filter(
bullet => !bulletsToRemove.includes(bullet),
);
// Update each player
Object.keys(this.sockets).forEach(playerID => {
const player = this.players[playerID];
const newBullet = player.update(dt);
if (newBullet) {
this.bullets.push(newBullet);
}
});
// Apply collisions, give players score for hitting bullets
const destroyedBullets = applyCollisions(
Object.values(this.players),
this.bullets,
);
destroyedBullets.forEach(b => {
if (this.players[b.parentID]) {
this.players[b.parentID].onDealtDamage();
}
});
this.bullets = this.bullets.filter(
bullet => !destroyedBullets.includes(bullet),
);
// Check if any players are dead
Object.keys(this.sockets).forEach(playerID => {
const socket = this.sockets[playerID];
const player = this.players[playerID];
if (player.hp <= 0) {
socket.emit(Constants.MSG_TYPES.GAME_OVER);
this.removePlayer(socket);
}
});
// Send a game update to each player every other time
if (this.shouldSendUpdate) {
const leaderboard = this.getLeaderboard();
Object.keys(this.sockets).forEach(playerID => {
const socket = this.sockets[playerID];
const player = this.players[playerID];
socket.emit(
Constants.MSG_TYPES.GAME_UPDATE,
this.createUpdate(player, leaderboard),
);
});
this.shouldSendUpdate = false;
} else {
this.shouldSendUpdate = true;
}
}
// ...
}
арга update()
сервер талын логикийн хамгийн чухал хэсгийг агуулсан байж магадгүй. Энэ нь юу хийдэг вэ, дарааллаар нь:
- Хэр удаан болохыг тооцоолно
dt
сүүлчийнхээс хойш өнгөрсөнupdate()
. - Сум тус бүрийг сэргээж, шаардлагатай бол устгана. Энэ функцийн хэрэгжилтийг бид дараа нь харах болно. Одоохондоо үүнийг мэдэхэд хангалттай
bullet.update()
буцаж ирдэгtrue
хэрэв сумыг устгах шаардлагатай бол (тэр талбайгаас гарав). - Тоглогч бүрийг шинэчилж, шаардлагатай бол сум гаргадаг. Бид энэ хэрэгжилтийг дараа нь харах болно -
player.update()
объектыг буцааж болноBullet
. - Пуужингууд болон тоглогчдын хооронд мөргөлдөх эсэхийг шалгана
applyCollisions()
, энэ нь тоглогчдыг оносон олон тооны сумыг буцаана. Буцсан сум бүрийн хувьд бид түүнийг харвасан тоглогчийн оноог нэмэгдүүлдэг (player.onDealtDamage()
) дараа нь сумыг массиваас салгаbullets
. - Бүх нас барсан тоглогчдыг мэдэгдэж, устгадаг.
- Тоглоомын шинэчлэлтийг бүх тоглогчдод илгээдэг секунд тутамд дуудсан удаа
update()
. Энэ нь дээр дурдсан туслах хувьсагчийг хянахад бидэнд тусалдаг.shouldSendUpdate
... Байдлаарupdate()
60 удаа/с дуудсан, бид тоглоомын шинэчлэлтийг 30 удаа/с илгээдэг. Тиймээс, цагийн давтамж серверийн цаг нь 30 цаг / с (бид эхний хэсэгт цагийн хурдны талаар ярьсан).
Яагаад зөвхөн тоглоомын шинэчлэлтийг илгээдэг вэ? цаг хугацаагаар ? Суваг хадгалахын тулд. Секундэд 30 тоглоомын шинэчлэлт нь маш их юм!
Яагаад зүгээр залгаж болохгүй гэж
update()
Секундэд 30 удаа? Тоглоомын симуляцийг сайжруулахын тулд. Илүү олон удаа дууддагupdate()
, тоглоомын загварчлал илүү нарийвчлалтай байх болно. Гэхдээ олон тооны сорилтод бүү хэт авт.update()
, учир нь энэ нь тооцоолоход үнэтэй ажил юм - секундэд 60 хангалттай.
Ангийн үлдсэн хэсэг Game
ашигласан туслах аргуудаас бүрдэнэ update()
:
game.js 3-р хэсэг
class Game {
// ...
getLeaderboard() {
return Object.values(this.players)
.sort((p1, p2) => p2.score - p1.score)
.slice(0, 5)
.map(p => ({ username: p.username, score: Math.round(p.score) }));
}
createUpdate(player, leaderboard) {
const nearbyPlayers = Object.values(this.players).filter(
p => p !== player && p.distanceTo(player) <= Constants.MAP_SIZE / 2,
);
const nearbyBullets = this.bullets.filter(
b => b.distanceTo(player) <= Constants.MAP_SIZE / 2,
);
return {
t: Date.now(),
me: player.serializeForUpdate(),
others: nearbyPlayers.map(p => p.serializeForUpdate()),
bullets: nearbyBullets.map(b => b.serializeForUpdate()),
leaderboard,
};
}
}
getLeaderboard()
маш энгийн - энэ нь тоглогчдыг оноогоор нь ангилж, эхний тавыг авч, хэрэглэгчийн нэр, оноог буцаана.
createUpdate()
-д ашигласан update()
тоглогчдод түгээдэг тоглоомын шинэчлэлийг бий болгох. Үүний гол үүрэг бол аргуудыг дуудах явдал юм serializeForUpdate()
ангиудад хэрэгжсэн Player
и Bullet
. Энэ нь зөвхөн тоглогч бүрт мэдээлэл дамжуулдаг гэдгийг анхаарна уу хамгийн ойр тоглогчид ба сумнууд - тоглогчоос хол байгаа тоглоомын объектын талаар мэдээлэл дамжуулах шаардлагагүй!
3. Сервер дээрх тоглоомын объектууд
Манай тоглоомонд сумнууд болон тоглогчид хоорондоо маш төстэй байдаг: тэдгээр нь хийсвэр, дугуй, хөдөлгөөнт тоглоомын объектууд юм. Тоглогчид болон сумны хоорондох ижил төстэй байдлыг ашиглахын тулд үндсэн ангиллыг хэрэгжүүлж эхэлцгээе Object
:
object.js
class Object {
constructor(id, x, y, dir, speed) {
this.id = id;
this.x = x;
this.y = y;
this.direction = dir;
this.speed = speed;
}
update(dt) {
this.x += dt * this.speed * Math.sin(this.direction);
this.y -= dt * this.speed * Math.cos(this.direction);
}
distanceTo(object) {
const dx = this.x - object.x;
const dy = this.y - object.y;
return Math.sqrt(dx * dx + dy * dy);
}
setDirection(dir) {
this.direction = dir;
}
serializeForUpdate() {
return {
id: this.id,
x: this.x,
y: this.y,
};
}
}
Энд ямар ч төвөгтэй зүйл байхгүй. Энэ анги нь өргөтгөлийн сайн зангуу цэг байх болно. Хичээл яаж байгааг харцгаая Bullet
ашигладаг Object
:
bullet.js
const shortid = require('shortid');
const ObjectClass = require('./object');
const Constants = require('../shared/constants');
class Bullet extends ObjectClass {
constructor(parentID, x, y, dir) {
super(shortid(), x, y, dir, Constants.BULLET_SPEED);
this.parentID = parentID;
}
// Returns true if the bullet should be destroyed
update(dt) {
super.update(dt);
return this.x < 0 || this.x > Constants.MAP_SIZE || this.y < 0 || this.y > Constants.MAP_SIZE;
}
}
Реализация Bullet
Маш намхан! Бид нэмсэн Object
зөвхөн дараах өргөтгөлүүд:
- Багц ашиглах
богинохон санамсаргүй үеийн хувьдid
сум. - Талбар нэмж байна
parentID
Ингэснээр та энэ сумыг бүтээсэн тоглогчийг хянах боломжтой. - -д буцах утгыг нэмж байна
update()
, энэ нь тэнцүү байнаtrue
хэрэв сум талбайн гадна байгаа бол (бид энэ талаар сүүлийн хэсэгт ярьж байсныг санаж байна уу?).
Дараа нь үргэлжлүүлье Player
:
player.js
const ObjectClass = require('./object');
const Bullet = require('./bullet');
const Constants = require('../shared/constants');
class Player extends ObjectClass {
constructor(id, username, x, y) {
super(id, x, y, Math.random() * 2 * Math.PI, Constants.PLAYER_SPEED);
this.username = username;
this.hp = Constants.PLAYER_MAX_HP;
this.fireCooldown = 0;
this.score = 0;
}
// Returns a newly created bullet, or null.
update(dt) {
super.update(dt);
// Update score
this.score += dt * Constants.SCORE_PER_SECOND;
// Make sure the player stays in bounds
this.x = Math.max(0, Math.min(Constants.MAP_SIZE, this.x));
this.y = Math.max(0, Math.min(Constants.MAP_SIZE, this.y));
// Fire a bullet, if needed
this.fireCooldown -= dt;
if (this.fireCooldown <= 0) {
this.fireCooldown += Constants.PLAYER_FIRE_COOLDOWN;
return new Bullet(this.id, this.x, this.y, this.direction);
}
return null;
}
takeBulletDamage() {
this.hp -= Constants.BULLET_DAMAGE;
}
onDealtDamage() {
this.score += Constants.SCORE_BULLET_HIT;
}
serializeForUpdate() {
return {
...(super.serializeForUpdate()),
direction: this.direction,
hp: this.hp,
};
}
}
Тоглогчид сумнаас илүү төвөгтэй тул энэ ангид хэд хэдэн талбайг хадгалах хэрэгтэй. Түүний арга update()
маш их ажил хийдэг, ялангуяа шинээр бий болсон сум байхгүй бол буцааж өгдөг fireCooldown
(Бид өмнөх хэсэгт энэ тухай ярьж байсныг санаж байна уу?). Мөн энэ нь аргыг өргөжүүлдэг serializeForUpdate()
, учир нь бид тоглоомын шинэчлэлд тоглогчийн нэмэлт талбаруудыг оруулах хэрэгтэй.
Суурь ангитай байх Object
- кодыг давтахаас зайлсхийх чухал алхам. Жишээлбэл, анги байхгүй Object
Тоглоомын объект бүр ижил хэрэгжилттэй байх ёстой distanceTo()
, мөн эдгээр бүх хэрэгжилтийг олон файл дээр хуулж буулгах нь хар дарсан зүүд байх болно. Энэ нь ялангуяа томоохон төслүүдэд чухал ач холбогдолтой юм.тоо өргөжих үед Object
ангиуд нэмэгдэж байна.
4. Мөргөлдөөнийг илрүүлэх
Бидэнд үлдсэн цорын ганц зүйл бол пуужин тоглогчдыг хэзээ онохыг мэдэх явдал юм! Аргын энэ кодын хэсгийг санаарай update()
ангид Game
:
game.js
const applyCollisions = require('./collisions');
class Game {
// ...
update() {
// ...
// Apply collisions, give players score for hitting bullets
const destroyedBullets = applyCollisions(
Object.values(this.players),
this.bullets,
);
destroyedBullets.forEach(b => {
if (this.players[b.parentID]) {
this.players[b.parentID].onDealtDamage();
}
});
this.bullets = this.bullets.filter(
bullet => !destroyedBullets.includes(bullet),
);
// ...
}
}
Бид арга барилаа хэрэгжүүлэх хэрэгтэй applyCollisions()
, энэ нь тоглогчдыг оносон бүх сумыг буцаана. Аз болоход үүнийг хийхэд тийм ч хэцүү биш
- Бүх мөргөлдөж буй объектууд нь тойрог бөгөөд мөргөлдөөнийг илрүүлэх хамгийн энгийн хэлбэр юм.
- Бидэнд аль хэдийн арга бий
distanceTo()
, бид өмнөх хэсэгт ангидаа хэрэгжүүлсэнObject
.
Мөргөлдөөнийг илрүүлэх бидний хэрэгжилт дараах байдалтай байна.
collisions.js
const Constants = require('../shared/constants');
// Returns an array of bullets to be destroyed.
function applyCollisions(players, bullets) {
const destroyedBullets = [];
for (let i = 0; i < bullets.length; i++) {
// Look for a player (who didn't create the bullet) to collide each bullet with.
// As soon as we find one, break out of the loop to prevent double counting a bullet.
for (let j = 0; j < players.length; j++) {
const bullet = bullets[i];
const player = players[j];
if (
bullet.parentID !== player.id &&
player.distanceTo(bullet) <= Constants.PLAYER_RADIUS + Constants.BULLET_RADIUS
) {
destroyedBullets.push(bullet);
player.takeBulletDamage();
break;
}
}
}
return destroyedBullets;
}
Энэхүү энгийн мөргөлдөөнийг илрүүлэх нь үүнд тулгуурладаг Хэрэв төвүүдийн хоорондох зай нь радиусуудын нийлбэрээс бага бол хоёр тойрог мөргөлдөнө. Хоёр тойргийн төвүүдийн хоорондох зай нь тэдгээрийн радиусуудын нийлбэртэй яг тэнцүү байх тохиолдол энд байна.
Энд хэд хэдэн зүйлийг анхаарч үзэх хэрэгтэй:
- Сум нь түүнийг бүтээсэн тоглогчийг онох ёсгүй. Үүнийг харьцуулах замаар хийж болно
bullet.parentID
сplayer.id
. - Олон тоглогч нэгэн зэрэг мөргөлдсөн тохиолдолд сум зөвхөн нэг удаа онох ёстой. Бид энэ асуудлыг оператор ашиглан шийдэх болно
break
: сумтай мөргөлдсөн тоглогч олдмогц бид хайлтаа зогсоож, дараагийн сум руу шилжинэ.
Төгсгөл
Тэгээд л болоо! Бид таны .io вэб тоглоом бүтээхэд хэрэгтэй бүх зүйлийг авч үзсэн. Дараа нь юу юм? Өөрийн гэсэн .io тоглоом бүтээгээрэй!
Бүх загвар код нь нээлттэй эх сурвалж бөгөөд дээр байрлуулсан
Эх сурвалж: www.habr.com