
Vydaná v roce 2015 se stala předchůdcem nového žánru , jehož popularita od té doby výrazně vzrostla. Růst popularity her .io jsem zažil na vlastní kůži: za poslední tři roky jsem .
Pro případ, že byste o takových hrách nikdy předtím neslyšeli: jsou to zdarma dostupné multiplayerové webové hry, do kterých se snadno zapojíte (není potřeba účet). Obvykle se na jednom aréně střetává mnoho konkurenčních hráčů. Dalšími známými hrami tohoto žánru jsou: a .
V tomto příspěvku si ukážeme, jak od nuly vytvořit hru .io. K tomu vám stačí pouze znalost JavaScriptu: musíte rozumět takovým věcem, jako je syntaxe , klíčové slovo this a . I když JavaScript neznáte dokonale, stejně byste měli být schopni zvládnout většinu příspěvku.
Příklad hry .io
Abychom usnadnili učení, budeme odkazovat na . Zkuste si v ní zahrát!

Hra je docela jednoduchá: ovládáte loď na aréně, kde jsou další hráči. Vaše loď automaticky vystřeluje projektily a vy se snažíte zasáhnout ostatní hráče, zatímco se vyhýbáte jejich projektilům.
1. Stručný přehled/struktura projektu
Doporučuji příkladu hry, abyste mi mohli následovat.
V příkladu se používá následující:
- — nejpopulárnější webový framework pro Node.js, který spravuje webový server hry.
- — websocketová knihovna pro výměnu dat mezi prohlížečem a serverem.
- — správce modulů. O tom, proč používat Webpack, si můžete přečíst .
Takhle vypadá struktura adresáře projektu:
public/
assets/
...
src/
client/
css/
...
html/
index.html
index.js
...
server/
server.js
...
shared/
constants.jspublic/
Všechno v adresáři public/ bude staticky předáváno serverem. V public/assets/ se nacházejí obrázky používané naším projektem.
src/
Veškerý zdrojový kód se nachází v adresáři src/. Název client/ a server/ mluví sám za sebe a shared/ obsahuje soubor konstant, který je importován jak klientem, tak serverem.
2. Sestavení/parametry projektu
Jak bylo uvedeno výše, pro sestavení projektu používáme správce modulů . Podívejme se na naši konfiguraci Webpack:
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',
}),
],
};Nejdůležitější jsou následující řádky:
src/client/index.js— to je vstupní bod pro klienta Javascript (JS). Webpack bude začínat odtud a rekurzivně hledat další importované soubory.- Výstupní JS naší Webpack sestavy bude umístěn v adresáři
dist/. Budu tomu říkat náš JS balík. - Používáme , a konkrétně konfiguraci pro transpiling našeho JS kódu pro staré prohlížeče.
- Používáme plugin pro extrakci všech CSS, na které se odkazují JS soubory, a pro jejich sloučení na jedno místo. Budu to nazývat naším CSS balíkem..
Možná jste si všimli podivných názvů souborů balíku '[name].[contenthash].ext'. Obsahují Webpack: [name] bude nahrazen názvem vstupního bodu (v našem případě je to game), a [contenthash] bude nahrazen hashem obsahu souboru. Děláme to, abychom abychom mohli prohlížečům nařídit neomezené cacheování našich JS balíků, protože pokud se balík změní, změní se i jeho název souboru (změní se contenthash). Hotovým výsledkem bude název souboru ve formátu game.dbeee76e91a97d0c7207.js.
Soubor webpack.common.js — to je základní konfigurační soubor, který importujeme do konfigurací vývoje a finálního projektu. Zde je například konfigurace pro vývoj:
webpack.dev.js
const merge = require('webpack-merge');
const common = require('./webpack.common.js');
module.exports = merge(common, {
mode: 'development',
}); Pro efektivitu používáme v procesu vývoje webpack.dev.js, a přepíná se na webpack.prod.js, aby optimalizoval velikost balíku při nasazení do produkce.
Místní nastavení
Doporučuji nainstalovat projekt na místním stroji, abyste mohli sledovat kroky uvedené v tomto příspěvku. Nastavení je jednoduché: nejprve musí být v systému nainstalováno a . Dále je potřeba provést
$ git clone https://github.com/vzhou842/example-.io-game.git
$ cd example-.io-game
$ npm installa jste připraveni k práci! Pro spuštění vývojového serveru stačí provést
$ npm run developa přejít do webového prohlížeče na . Vývojový server bude automaticky znovu sestavovat balíčky JS a CSS během úprav kódu — jen obnovte stránku, abyste viděli všechny změny!
3. Klientské vstupní body
Pojďme se pustit do samotného kódu hry. Nejprve potřebujeme stránku index.html, která bude načtena jako první při návštěvě webu. Naše stránka bude poměrně jednoduchá:
index.html
<!DOCTYPE html>
<html>
<head>
<title>Příklad hry .io</title>
<link type="text/css" rel="stylesheet" href="/game.bundle.css">
</head>
<body>
<canvas id="game-canvas"></canvas>
<script async src="/game.bundle.js"></script>
<div id="play-menu" class="hidden">
<input type="text" id="username-input" placeholder="Uživatelské jméno" />
<button id="play-button">HRÁT</button>
</div>
</body>
</html>Tento kód je mírně zjednodušen pro přehlednost, to samé udělám i s mnoha dalšími příklady příspěvku. Kompletní kód si můžete prohlédnout na .
Máme zde:
- (
<canvas>), který budeme používat pro vykreslování hry. <link>pro přidání našeho balíčku CSS.<script>pro přidání našeho balíčku JavaScriptu.- Hlavní menu s uživatelským jménem
, prvkya tlačítko "PLAY" ().
Po načtení domovské stránky se v prohlížeči začne spouštět JavaScriptový kód, počínaje vstupním JS souborem: 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 = () => {
// Hrajte!
play(usernameInput.value);
playMenu.classList.add('hidden');
initState();
startCapturingInput();
startRendering();
setLeaderboardHidden(false);
};
});To může vypadat složitě, ale ve skutečnosti se zde neděje tolik akcí:
- Import několika dalších JS souborů.
- Import CSS (aby Webpack věděl, že je třeba je zahrnout do našeho CSS balíčku).
- Spuštění
connect()pro navázání spojení se serverem a spuštěnídownloadAssets()pro stažení obrázků potřebných pro vykreslování hry. - Po dokončení kroku 3 se zobrazí hlavní menu (
playMenu). - Nastavení obsluhy kliknutí na tlačítko "PLAY". Po stisknutí tlačítka kód inicializuje hru a informuje server, že jsme připraveni hrát.
Hlavní "maso" naší klient-server logiky se nachází v těch souborech, které byly importovány souborem index.js. Teď si je probereme postupně.
4. Výměna dat klienta
V této hře pro komunikaci se serverem používáme dobře známou knihovnu . Socket.io má vestavěnou podporu , které se dobře hodí pro obousměrnou komunikaci: můžeme posílat zprávy serveru a server nám může posílat zprávy přes stejné spojení.
Budeme mít jeden soubor src/client/networking.js, který se postará o všechny komunikace se serverem:
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('Připojeno k serveru!');
resolve();
});
});
export const connect = onGameOver => (
connectedPromise.then(() => {
// Registrace callbacků
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);
};Tento kód je pro srozumitelnost také mírně zkrácen.
V tomto souboru probíhají tři hlavní činnosti:
- Zkoušíme se připojit k serveru.
connectedPromiseje vyřešena pouze tehdy, když navážeme spojení. - Pokud je spojení úspěšně navázáno, registrujeme callback funkce (
processGameUpdate()aonGameOver()) pro zprávy, které můžeme od serveru přijímat. - Exportujeme
play()aupdateDirection(), aby je mohly používat jiné soubory.
5. Renderování klienta
Nastala doba, abychom na obrazovce zobrazili obrázek!
…ale než to uděláme, musíme stáhnout všechny obrázky (zdroje), které k tomu potřebujeme. Pojďme napsat správce zdrojů:
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(`Staženo ${assetName}`);
assets[assetName] = asset;
resolve();
};
asset.src = `/assets/${assetName}`;
});
}
export const downloadAssets = () => downloadPromise;
export const getAsset = assetName => assets[assetName]; Správa zdrojů není tak složitá! Hlavní smysl spočívá v tom, uchovávat objekt assets, který bude mapovat klíč názvu souboru na hodnotu objektu Obrázek. Jakmile se zdroj nahraje, uložíme ho do objektu assets pro rychlý přístup v budoucnu. Jakmile bude povoleno stažení každého jednotlivého zdroje (to znamená, že budou nahrány všechny zdroje), povolíme downloadPromise.
Po stažení zdrojů můžeme přistoupit k renderování. Jak už bylo řečeno, k vykreslování na webové stránce používáme (<canvas>). Naše hra je poměrně jednoduchá, takže stačí vykreslit následující:
- Pozadí
- Hráčova loď
- Ostatní hráče, kteří jsou ve hře
- Střely
Zde jsou důležité úseky src/client/render.js, které vykreslují právě výše zmíněné čtyři body:
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;
// Získejte grafický kontext plátna
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');
// Nastavte plátno na celou obrazovku
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
function render() {
const { me, others, bullets } = getCurrentState();
if (!me) {
return;
}
// Vykreslení pozadí
renderBackground(me.x, me.y);
// Vykreslení všech střel
bullets.forEach(renderBullet.bind(null, me));
// Vykreslení všech hráčů
renderPlayer(me, me);
others.forEach(renderPlayer.bind(null, me));
}
// ... Pomocné funkce zde vyloučeny
let renderInterval = null;
export function startRendering() {
renderInterval = setInterval(render, 1000 / 60);
}
export function stopRendering() {
clearInterval(renderInterval);
}Tento kód je také zkrácen pro přehlednost.
render() — to je hlavní funkce tohoto souboru. startRendering() a stopRendering() kontrolují aktivaci renderovacího cyklu při frekvenci 60 FPS.
Konkrétní implementace jednotlivých pomocných renderovacích funkcí (například renderBullet()) nejsou tak důležité, ale tady je jeden jednoduchý příklad:
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,
);
} Všimněte si, že používáme metodu getAsset(), kterou jsme již viděli v asset.js!
Pokud máte zájem o prozkoumání dalších pomocných renderovacích funkcí, přečtěte si zbytek .
6. Klientský vstup
Je čas udělat hru hratelnou! Schéma ovládání bude velmi jednoduché: pro změnu směru pohybu můžete použít myš (na počítači) nebo dotyk na obrazovce (na mobilním zařízení). Abychom to realizovali, zaregistrujeme pro události Mouse a Touch.
O vše se postará 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() a onTouchInput() — to jsou Event Listeners, které vyvolávají updateDirection() (při networking.js) při provádění vstupní události (například při pohybu myši). updateDirection() provádí výměnu zpráv se serverem, který zpracovává události vstupu a podle toho aktualizuje stav hry.
7. Stav klienta
Tato část je nejobtížnější v první části příspěvku. Nezoufejte, pokud ji hned na poprvé nepochopíte! Můžete ji dokonce přeskočit a vrátit se k ní později.
Poslední kousek skládačky, který potřebujeme k dokončení klientsko-serverového kódu, je stav. Pamatujete si úryvek kódu z části „Rendering klienta“?
render.js
import { getCurrentState } from './state';
function render() {
const { me, others, bullets } = getCurrentState();
// Provádějte rendering
// ...
} getCurrentState() musí být schopna nám poskytnout aktuální stav hry na klientovi v jakémkoli okamžiku na základě aktualizací, které získáváme ze serveru. Zde je příklad aktualizace hry, kterou může server zaslat:
{
"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": "Hráč",
"score": 3
}
]
}Každá aktualizace hry obsahuje pět stejných polí:
- t: časové razítko serveru, které označuje moment vytvoření této aktualizace.
- já: informace o hráči, který tuto aktualizaci získává.
- ostatní: pole informací o dalších hráčích, kteří se účastní stejné hry.
- projektily: pole informací o projektilu ve hře.
- tabulka nejlepších: aktuální data tabulky nejlepších hráčů. V tomto příspěvku je nebudeme zohledňovat.
7.1 Naivní stav klienta
Naivní implementace getCurrentState() může pouze přímo vrátit data poslední přijaté aktualizace hry.
naive-state.js
let lastGameUpdate = null;
// Zpracovat nově přijatou aktualizaci hry.
export function processGameUpdate(update) {
lastGameUpdate = update;
}
export function getCurrentState() {
return lastGameUpdate;
}Hezké a jasné! Ale pokud by to bylo tak snadné. Jeden z důvodů, proč je taková implementace problematická: omezuje frekvenci snímků renderování frekvencí taktů serveru.
Frekvence snímků (Frame Rate): počet snímků (tj. volání
render()) za sekundu, nebo FPS. V hrách se obvykle snaží dosáhnout alespoň 60 FPS.
Frekvence taktů (Tick Rate): frekvence, s jakou server zasílá aktualizace hry klientům. Často je nižší než frekvence snímků. V naší hře server pracuje s frekvencí 30 taktů za sekundu.
Pokud budeme pouze vykreslovat poslední aktualizaci hry, FPS nikdy nepřesáhne 30, protože nikdy nedostáváme od serveru více než 30 aktualizací za sekundu. I když byste to zavolali render() 60krát za sekundu, polovina těchto volání by pouze překreslovala to samé, v podstatě by nedělala nic. Další problém s naivní implementací je, že je citlivá na zpoždění. Při ideální rychlosti internetu klient dostane aktualizaci hry každých 33 ms (30 za sekundu):

Bohužel nic není dokonalé. Realističtější pohled by byl takový:

Naivní implementace je prakticky nejhorší případ, pokud jde o zpoždění. Pokud je aktualizace hry přijata se zpožděním 50 ms, klient se zpožďuje o dalších 50 ms, protože stále vykresluje stav hry z předchozí aktualizace. Můžete si představit, jak nepříjemné to pro hráče je: kvůli náhodným zpožděním se hra zdá trhaná a nestabilní.
7.2 Vylepšený stav klienta
Pro naivní implementaci provedeme několik vylepšení. Za prvé, použijeme zpoždění vykreslování o 100 ms. To znamená, že ‚aktuální‘ stav klienta bude vždy za stavem hry na serveru o 100 ms. Například, pokud je čas na serveru 150, bude na klientovi vykreslen stav, ve kterém byl server v okamžiku 50:

To nám dává buffer 100 ms, což umožňuje překonat nepředvídatelnou dobu příjmu aktualizací hry:

Na to doplatíme trvalým o 100 ms. To je zanedbatelná oběť za plynulý herní zážitek — většina hráčů (zejména příležitostných) si této prodlevy ani nevšimne. Lidem se mnohem snáze přizpůsobuje trvalé zpoždění 100 ms než hraní s nepředvídatelným zpožděním.
Můžeme také použít jinou techniku nazvanou , která účinně snižuje vnímaná zpoždění, ale v tomto příspěvku nebude diskutována.
Další vylepšení, které využíváme, je lineární interpolace. Díky zpoždění vykreslování obvykle obcházíme aktuální čas v klientovi alespoň o jednu aktualizaci. Když je zavoláno getCurrentState(), můžeme provést mezi aktualizacemi hry bezprostředně před a po aktuálním čase v klientovi:

To řeší problém s počtem snímků: nyní můžeme vykreslovat unikátní snímky libovolnou potřebnou frekvencí!
7.3 Implementace vylepšeného stavu klienta
Příklad implementace v src/client/state.js používá jak zpoždění renderování, tak lineární interpolaci, ale to netrvá dlouho. Pojďme rozdělit kód na dvě části. Zde je první:
state.js, část 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);
// Uchováme pouze jednu hru aktualizaci před aktuálním časem serveru
const base = getBaseUpdate();
if (base > 0) {
gameUpdates.splice(0, base);
}
}
function currentServerTime() {
return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY;
}
// Vrací index základní aktualizace, první herní aktualizace před
// aktuálním serverovým časem, nebo -1 pokud 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;
} Nejprve se musíme vyrovnat s tím, co dělá currentServerTime(). Jak jsme viděli dříve, každé aktualizaci hry je přidán serverový časový údaj. Chceme použít zpoždění renderování, abychom vykreslovali obrázek se zpožděním 100 ms za serverem, ale nikdy se nedozvíme, jaký je aktuální čas na serveru, protože nemůžeme vědět, jak dlouho trvalo, než k nám dorazila jakákoli ze aktualizací. Internet je nepředvídatelný a jeho rychlost se může velmi lišit!
Abychom se této problematice vyhnuli, můžeme použít rozumnou aproximaci: budeme předstírat, že první aktualizace dorazila okamžitě. Kdyby to byla pravda, věděli bychom čas serveru v tuto konkrétní chvíli! Uložíme serverový časový údaj do firstServerTimestamp a uložíme naši lokální (klientskou) časovou značku ve stejném okamžiku do gameStart.
Omlouvám se, chvíli počkejte. Nemělo by být čas na serveru = čas v klientovi? Proč odlišujeme „časový údaj serveru“ a „časový údaj klienta“? To je vynikající otázka! Ukazuje se, že to není totéž. Date.now() bude vracet různé časové údaje v klientovi i serveru a závisí to na lokálních faktorech těchto strojů. Nikdy nepředpokládejte, že časové údaje budou stejné na všech strojích.
Nyní chápeme, co dělá currentServerTime(): vrátí časový údaj serveru aktuálního času renderování. Jinými slovy, je to aktuální čas serveru (firstServerTimestamp + (Date.now() - gameStart)) mínus zpoždění renderování (RENDER_DELAY).
Teď se pojďme podívat, jak zpracováváme aktualizace hry. Po obdržení aktualizace ze serveru se volá processGameUpdate(), a nové aktualizace uložení do pole gameUpdates. Poté, abychom zkontrolovali využití paměti, odstraníme všechny staré aktualizace až do základní aktualizace, protože už je nepotřebujeme.
Co to vlastně je „základní aktualizace“? Je to první aktualizace, kterou najdeme, když se posuneme zpět od aktuálního času serveru. Pamatujete si tuto schéma?

Aktualizace hry přímo vlevo od „Client Render Time“ je základní aktualizace.
K čemu se základní aktualizace používá? Proč můžeme zahodit aktualizace až k základní? Abychom na to přišli, podívejme se na konečně implementaci getCurrentState():
state.js, část 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),
};
}
}Zpracováváme tři případy:
base < 0označuje, že k aktuálnímu času renderování nejsou žádné aktualizace (viz výše implementacigetBaseUpdate()). K tomu může dojít hned na začátku hry kvůli zpoždění renderování. V takovém případě použijeme poslední obdrženou aktualizaci.baseje nejnovější aktualizace, kterou máme. K tomu může dojít kvůli síťovému zpoždění nebo špatnému připojení k internetu. V takovém případě také použijeme poslední aktualizaci, kterou máme.- Máme aktualizaci jak před, tak po aktuálním čase renderování, takže můžeme interpolovat!
Vše, co zbývá v state.js je implementace lineární interpolace, což je jednoduchá (ale nudná) matematika. Pokud se chcete naučit s tím pracovat, podívejte se na state.js na .
Část 2. Backendový server
V této části se podíváme na backend Node.js, který spravuje náš .
1. Vstupní bod serveru
Pro správu webového serveru použijeme populární webový framework pro Node.js nazvaný . Jeho nastavení se postará náš vstupní bod serveru src/server/server.js:
server.js, část 1
const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackConfig = require('..\/..\/webpack.dev.js');
// Nastavení Express serveru
const app = express();
app.use(express.static('public'));
if (process.env.NODE_ENV === 'development') {
// Nastavení Webpack pro vývoj
const compiler = webpack(webpackConfig);
app.use(webpackDevMiddleware(compiler));
} else {
// Statické servírování složky dist\/ v produkci
app.use(express.static('dist'));
}
// Naslouchání na portu
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Server naslouchá na portu ${port}`);Pamatujte, že jsme v první části diskutovali o Webpacku? Právě zde budeme používat naše konfigurace Webpack. Aplikujeme je dvěma způsoby:
- Použít pro automatické přepracování našich vývojových balíčků, nebo
- Staticky předávat složku
dist/, do které Webpack zapíše naše soubory po sestavení produkce.
Další důležitý úkol server.js spočívá v nastavení serveru , který se jednoduše připojuje k serveru Express:
server.js, část 2
const socketio = require('socket.io');
const Constants = require('..\/shared\/constants');
// Nastavení Express
// ...
const server = app.listen(port);
console.log(`Server naslouchá na portu ${port}`);
// Nastavení socket.io
const io = socketio(server);
// Naslouchání na socket.io připojeních
io.on('connection', socket => {
console.log('Hráč připojen!', socket.id);
socket.on(Constants.MSG_TYPES.JOIN_GAME, joinGame);
socket.on(Constants.MSG_TYPES.INPUT, handleInput);
socket.on('disconnect', onDisconnect);
}); Po úspěšném navázání spojení socket.io se serverem nastavujeme zpracovatele událostí pro nový socket. Zpracovatelé událostí zpracovávají zprávy přijímané od klientů delegováním na singleton objekt game:
server.js, část 3
const Game = require('.\/game');
// ...
// Nastavení hry
const game = new Game();
function joinGame(username) {
game.addPlayer(this, username);
}
function handleInput(dir) {
game.handleInput(this, dir);
}
function onDisconnect() {
game.removePlayer(this);
} Vytváříme hru typu .io, takže nám bude stačit pouze jedna instance Game („Hra“) – všichni hráči hrají na jedné aréně! V další části se podíváme, jak tento třída funguje Game.
2. Serverová hra
Třída Game má nejdůležitější logiku na straně serveru. Má dvě hlavní úkoly: správa hráčů a simulace hry.
Začněme s prvním úkolem – s řízením hráčů.
game.js, část 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;
// Vytvoření pozice pro začátek tohoto hráče.
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);
}
}
// ...
} V této hře budeme identifikovat hráče pomocí pole id jeho socketu socket.io (pokud jste zmatení, vraťte se k server.js). Socket.io sám přiřazuje každému socketu jedinečný id, takže se o to nemusíme starat. Budu to nazývat ID hráče.
Jakmile si to zapamatujete, pojďme prozkoumat proměnné instance v třídě Game:
sockets— je to objekt, který mapuje ID hráče na socket, který je spojen s hráčem. Umožňuje nám přístup k socketům podle jejich ID hráčů v konstantním čase.players— je to objekt, který mapuje ID hráče na objekt code>Player
projektily — je to pole objektů Bullet, které nemá žádný konkrétní pořádek.
lastUpdateTime — je to časová značka poslední aktualizace hry. Brzy uvidíme, jak se používá.
shouldSendUpdate — je to pomocná proměnná. Její použití brzy také uvidíme.
Metody addPlayer(), removePlayer() a handleInput() nevyžadují další vysvětlení, používají se v server.js. Pokud potřebujete osvěžit paměť, vraťte se o kus výše.
Poslední řádek constructor() spouští aktualizační smyčku hry (s frekvencí 60 aktualizací/s):
game.js, část 2
const Constants = require('..\/shared\/constants');
const applyCollisions = require('.\/collisions');
class Game {
\/\/ ...
update() {
\/\/ Vypočítat uplynulý čas
const now = Date.now();
const dt = (now - this.lastUpdateTime) \/ 1000;
this.lastUpdateTime = now;
\/\/ Aktualizuje každou střelu
const bulletsToRemove = [];
this.bullets.forEach(bullet => {
if (bullet.update(dt)) {
\/\/ Zničí tuto střelu
bulletsToRemove.push(bullet);
}
});
this.bullets = this.bullets.filter(
bullet => !bulletsToRemove.includes(bullet),
);
\/\/ Aktualizuje každého hráče
Object.keys(this.sockets).forEach(playerID => {
const player = this.players[playerID];
const newBullet = player.update(dt);
if (newBullet) {
this.bullets.push(newBullet);
}
});
\/\/ Aplikuje kolize, dává hráčům skóre za zásahy do střel
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),
);
\/\/ Kontroluje, zda jsou nějací hráči mrtví
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);
}
});
\/\/ Posílá každému hráči aktualizaci hry jednou za dvě doby
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;
}
}
\/\/ ...
} Metoda update() obsahuje pravděpodobně nejdůležitější část logiky na straně serveru. Pořadí je následující:
- Vypočítá, kolik času
dtuplynulo od posledníhoupdate(). - Aktualizuje každý náboj a v případě potřeby je ničí. Realizaci této funkce uvidíme později. Prozatím stačí vědět, že
bullet.update()vracítrue, pokud má být náboj zničen (vyšel za hranice arény). - Aktualizuje každého hráče a v případě potřeby vytvoří náboj. Tuto realizaci také uvidíme později —
player.update()může vrátit objektBullet. - Kontroluje kolize mezi náboji a hráči pomocí
applyCollisions(), který vrací pole nábojů, které zasáhly hráče. Pro každý vrácený náboj zvyšujeme body hráče, který jej vystřelil (pomocíplayer.onDealtDamage()), a pak odstraníme náboj z poleprojektily. - Upozorňuje a ničí všechny mrtvé hráče.
- Odesílá všem hráčům aktualizaci hry každý druhý krát při volání
update(). To nám pomáhá sledovat výše zmíněnou pomocnou proměnnoushouldSendUpdate. Takžeupdate()volá se 60krát/s, aktualizace hry posíláme 30krát/s. Tímto způsobem, frekvence hodin serveru je 30 taktů/s (o frekvenci hodin jsme hovořili v první části).
Proč posílat aktualizace hry pouze pořádku ? Pro úsporu šířky pásma. 30 aktualizací hry za sekundu – to je opravdu hodně!
Proč by se tedy nemělo volat
update()30krát za sekundu? Pro zlepšení simulace hry. Čím častěji je volánoupdate(), tím přesnější bude simulace hry. Ale neměli bychom to přehánět s počtem voláníupdate(), protože to je výpočetně náročný úkol — 60krát za sekundu je naprosto dost.
Zbytek třídy Game se skládá z pomocných metod používaných v update():
game.js, část 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) b.distanceTo(player) p.serializeForUpdate()),
bullets: nearbyBullets.map(b => b.serializeForUpdate()),
leaderboard,
};
}
} getLeaderboard() je poměrně jednoduchá – seřadí hráče podle počtu bodů, vezme pět nejlepších a vrátí pro každého uživatelské jméno a skóre.
createUpdate() se používá v update() k vytváření aktualizací hry, které jsou předávány hráčům. Jeho hlavní úkol je volání metod serializeForUpdate(), implementovaných pro třídy Player a Bullet. Všimněte si, že každému hráči posílá pouze informace o blízkých hráčích a střelách – není třeba předávat informace o herních objektech, které jsou od hráče daleko!
3. Herní objekty na serveru
V naší hře jsou střely a hráči skutečně velmi podobní: jsou to abstraktní kulaté pohyblivé herní objekty. Abychom využili tuto podobnost mezi hráči a střelami, začněme realizací základní třídy 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,
};
}
} Není to nic složitého. Tato třída bude dobrým výchozím bodem pro rozšíření. Podívejme se, jak třída Bullet používá 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;
}
\/\/ Vrátí true, pokud by měl být projektil zničen
update(dt) {
super.update(dt);
return this.x Constants.MAP_SIZE || this.y Constants.MAP_SIZE;
}
} Implementace Bullet je velmi krátká! Přidali jsme k Object pouze následující rozšíření:
- Použití balíčku pro náhodné generování
idprojektilu. - Přidání pole
parentID, aby bylo možné sledovat hráče, který tento projektil vytvořil. - Přidání návratové hodnoty do
update(), která je rovnatrue, pokud je projektil mimo arénu (pamatujte, o tom jsme hovořili v minulém oddílu?).
Přejdeme k 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;
}
\/\/ Vrací nově vytvořený projektil nebo null.
update(dt) {
super.update(dt);
\/\/ Aktualizace skóre
this.score += dt * Constants.SCORE_PER_SECOND;
\/\/ Ujistěte se, že hráč zůstává v mezích
this.x = Math.max(0, Math.min(Constants.MAP_SIZE, this.x));
this.y = Math.max(0, Math.min(Constants.MAP_SIZE, this.y));
\/\/ Vystřelit projektil, pokud je to nutné
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,
};
}
} Hráči jsou složitější než projektily, proto by třída měla obsahovat ještě několik dalších polí. Jeho metoda update() vykonává větší práci, zejména vrací právě vytvořený projektil, pokud nezůstalo fireCooldown (pamatujte, o tom jsme hovořili v minulém oddílu?). Také rozšiřuje metodu serializeForUpdate(), protože musíme do aktualizace hry zahrnout další pole pro hráče.
Mít základní třídu Object je důležitým krokem k tomu, abychom se vyhnuli opakování kódu. Například bez třídy Object by každý herní objekt musel mít stejnou implementaci distanceTo(), a synchronizace kopírování a vkládání všech těchto implementací v několika souborech by byla noční můra. To se stává zvlášť důležité pro velké projekty, kdy se počet rozšiřujících Object tříd zvyšuje.
4. Rozpoznávání kolizí
Jediné, co nám zbývá, je rozpoznat, kdy granáty zasáhnou hráče! Vzpomněňte si na tento úryvek kódu z metody update() v třídě Game:
game.js
const applyCollisions = require('./collisions');
class Game {
// ...
update() {
// ...
// Aplikovat kolize, udělit hráčům skóre za zásah granáty
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),
);
// ...
}
} Musíme implementovat metodu applyCollisions(), která vrací všechny granáty, které zasáhly hráče. Naštěstí to není tak složité udělat, protože
- Všechny kolidující objekty jsou kruhy, a to je nejjednodušší tvar pro implementaci rozpoznávání kolizí.
- Máme již metodu
distanceTo(), kterou jsme v předchozí části implementovali v tříděObject.
Takto vypadá naše implementace rozpoznávání kolizí:
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;
}Tohle jednoduché rozpoznávání kolizí je založeno na tom, že dva kruhy se srážejí, pokud je vzdálenost mezi jejich středy menší než součet jejich poloměrů. Tady je případ, kdy je vzdálenost mezi středy dvou kruhů přesně rovna součtu jejich poloměrů:

Zde je třeba věnovat pozornost ještě několika aspektům:
- Granát nesmí zasáhnout hráče, který ho vytvořil. Toho dosáhneme porovnáním
bullet.parentIDsplayer.id. - Granát by měl zasáhnout pouze jednou v případě současného střetu s několika hráči. Tento problém vyřešíme s pomocí příkazu
break: jakmile najdeme hráče, který se s granátem srazil, zastavíme hledání a přejdeme k dalšímu granátu.
Konec
A je to! Probrali jsme vše, co je potřebné znát pro vytvoření webové hry žánru .io. Co dál? Vytvořte si vlastní hru .io!
Celý kód příkladu je otevřený a zveřejněný na .
Zdroj: habr.com
