
Utgiven 2015 blev föregÄngaren till en ny genre , vars popularitet har ökat kraftigt sedan dess. Jag har sjÀlv upplevt tillvÀxten av populariteten för .io-spel: under de senaste tre Ären har jag .
Om du aldrig har hört talas om sÄdana spel tidigare: det Àr gratis multiplayer-webbspel som Àr lÀtta att delta i (utan konto krÀvs). De sÀtts vanligtvis pÄ en arena med mÄnga motstridiga spelare. Andra kÀnda spel inom .io-genren: och .
I det hĂ€r inlĂ€gget ska vi undersöka hur vi frĂ„n grunden skapar ett .io-spel. För detta behövs endast kunskaper i Javascript: du behöver förstĂ„ saker som syntaxen , nyckelordet this och . Ăven om du inte Ă€r fullĂ€rd i Javascript kommer du Ă€ndĂ„ kunna förstĂ„ det mesta av inlĂ€gget.
Exempel pÄ ett .io-spel
För att hjÀlpa till med lÀrandet kommer vi att referera till . Försök att spela det!

Spelet Àr ganska enkelt: du styr ett fartyg pÄ arenan dÀr det finns andra spelare. Ditt fartyg skjuter automatiskt projektiler och du försöker trÀffa andra spelare, samtidigt som du undviker deras projektiler.
1. Ăversikt/struktur av projektet
Jag rekommenderar för exemplet pÄ spelet sÄ att du kan följa med mig.
Exemplet anvÀnder följande:
- â det mest populĂ€ra webb-ramverket för Node.js som styr spelets webbserver.
- â ett websocket-bibliotek för att överföra data mellan webblĂ€saren och servern.
- â modulhanterare. Om varför man ska anvĂ€nda Webpack kan man lĂ€sa .
SÄ hÀr ser projektkatalogens struktur ut:
public/
assets/
...
src/
client/
css/
...
html/
index.html
index.js
...
server/
server.js
...
shared/
constants.jspublic/
Allt i mappen public/ kommer att överföras statiskt av servern. I public/assets/ finns de bilder som vÄrt projekt anvÀnder.
src/
All kÀllkod finns i mappen src/. Namnen client/ och server/ talas för sig sjÀlva, och shared/ innehÄller en konstantfil som importeras bÄde av klienten och servern.
2. Byggprocesser/parameter för projektet
Som nÀmnts ovan anvÀnder vi modulhanteraren för att bygga projektet. LÄt oss ta en titt pÄ vÄr Webpack-konfiguration:
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',
}),
],
};De viktigaste raderna hÀr Àr:
src/client/index.jsâ detta Ă€r klientens Javascript (JS) intrĂ€despunkt. Webpack kommer att börja hĂ€rifrĂ„n och rekursivt leta efter andra importerade filer.- UtgĂ„ende JS frĂ„n vĂ„r Webpack-bygge kommer att finnas i katalogen
dist/. Jag kommer att kalla denna fil vÄr JS-packe. - Vi anvÀnder , och specifikt konfigurationen för att transpila vÄr JS-kod för Àldre webblÀsare.
- Vi anvÀnder en plugin för att extrahera all CSS som refereras av JS-filer och för att sammanfoga dem pÄ ett stÀlle. Jag kommer att kalla det vÄr CSS-packe.
Du kanske har lagt mĂ€rke till de konstiga filnamnen för paketen '[name].[contenthash].ext'. De innehĂ„ller Webpack: [name] kommer att ersĂ€ttas med namnet pĂ„ intrĂ€despunkten (i vĂ„rt fall Ă€r det game), och [contenthash] kommer att ersĂ€ttas med hash av filens innehĂ„ll. Vi gör detta för att â det gör att webblĂ€sare kan cache-lagra vĂ„ra JS-paket oĂ€ndligt, eftersom om paketet Ă€ndras, Ă€ndras Ă€ven filens namn (Ă€ndras contenthash). Det fĂ€rdiga resultatet kommer att vara filnamn av typen game.dbeee76e91a97d0c7207.js.
Fil webpack.common.js â detta Ă€r basfilen för konfigurationen som vi importerar i utvecklings- och produktionskonfigurationen. HĂ€r Ă€r till exempel utvecklingskonfigurationen:
webpack.dev.js
const merge = require('webpack-merge');
const common = require('./webpack.common.js');
module.exports = merge(common, {
mode: 'development',
}); För effektivitet anvÀnder vi i utvecklingsprocessen webpack.dev.js, och vÀxlar till webpack.prod.js, för att optimera paketstorlekar vid utplacering till produktion.
Lokal installation
Jag rekommenderar att du installerar projektet pÄ din lokala maskin, sÄ att du kan följa stegen som anges i detta inlÀgg. Installation Àr enkel: först mÄste och vara installerade. Efter det behöver du köra
$ git clone https://github.com/vzhou842/example-.io-game.git
$ cd example-.io-game
$ npm installoch du Àr redo att börja! För att starta utvecklingsservern rÀcker det med att köra
$ npm run developoch gĂ„ till webblĂ€saren pĂ„ . Utvecklingsservern kommer automatiskt att Ă„terskapa JS- och CSS-paketen under koden Ă€ndras â uppdatera bara sidan för att se alla Ă€ndringar!
3. Klientpunkter
LÄt oss börja med sjÀlva spelkoden. Först behöver vi sidan index.html, nÀr man besöker webbplatsen kommer den att laddas först. VÄr sida kommer att vara ganska enkel:
index.html
<!DOCTYPE html>
<html>
<head>
<title>Ett exempel på ett .io-spel</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="Användarnamn" />
<button id="play-button">SPELA</button>
</div>
</body>
</html>Detta kodexempel Àr lite förenklat för att vara tydligt, och detsamma kommer jag att göra med mÄnga andra exempel i inlÀgget. Den kompletta koden kan alltid ses pÄ .
Vi har:
- (
<canvas>), vilket vi kommer att anvÀnda för att rendera spelet. <link>för att lÀgga till vÄrt CSS-paket.<script>för att lÀgga till vÄrt Javascript-paket.- Huvudmeny med anvÀndarnamn
<input>och knappen «SPELA» ().
Efter att startsidan har laddats i webblÀsaren kommer Javascript-koden att köras, med början frÄn ingÄngs-JS-filen: 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 = () => {
// Spela!
play(usernameInput.value);
playMenu.classList.add('hidden');
initState();
startCapturingInput();
startRendering();
setLeaderboardHidden(false);
};
});Det kan verka komplicerat, men egentligen hÀnder det inte sÄ mycket hÀr:
- Importera flera andra JS-filer.
- Importera CSS (sÄ att Webpack vet att det ska inkludera dem i vÄrt CSS-paket).
- Launch
connect()för att etablera en anslutning till servern och startadownloadAssets()för att ladda ner bilder som behövs för att rendera spelet. - Efter att steg 3 Àr slutfört visas huvudmenyn (
playMenu). - StÀlla in en handlare för knappen «SPELA». NÀr knappen trycks pÄ, initierar koden spelet och meddelar servern att vi Àr redo att spela.
Huvudlogiken för vÄr klient-server-interaktion finns i de filer som importerades av filen index.js. Nu ska vi gÄ igenom dem i tur och ordning.
4. Klientdatautbyte
I detta spel anvÀnder vi ett vÀlkÀnt bibliotek för att kommunicera med servern . Socket.io har inbyggt stöd , som Àr vÀl lÀmpade för tvÄvÀgskommunikation: vi kan skicka meddelanden till servern och och servern kan skicka meddelanden till oss via samma anslutning.
Vi kommer att ha en fil src/client/networking.js, som kommer att hantera all kommunikation med servern: 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('Ansluten till servern!'); resolve(); }); });export const connect = onGameOver => ( connectedPromise.then(() => { \/\/ Registrera 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); };
networking.js
Den hÀr koden Àr ocksÄ nÄgot förkortad för tydlighet.I denna fil sker tre huvudÄtgÀrder:
Vi försöker ansluta till servern.
- connectedPromise
upprÀttas först nÀr vi har upprÀttat en anslutning.Om anslutningen Àr framgÄngsrik registrerar vi callback-funktioner ( - processGameUpdate()
onGameOver()och) för meddelanden som vi kan fÄ frÄn servern.Vi exporterar - play()
updateDirection()och, sÄ att andra filer kan anvÀnda dem.5. Klientrendering
Det Àr dags att visa bilden pÄ skÀrmen!
âŠmen innan vi kan göra det mĂ„ste vi ladda ner alla bilder (resurser) som behövs för detta. LĂ„t oss skriva en resursförvaltare:
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(`Nedladdad ${assetName}`); assets[assetName] = asset; resolve(); }; asset.src = `\/assets\/${assetName}`; }); }export const downloadAssets = () => downloadPromise; export const getAsset = assetName => assets[assetName];
assets.js
Att hantera resurser Àr inte sÄ svÄrt! HuvudpoÀngen Àr att lagra ett objekt assets , som kopplar nyckeln av filnamnet till vÀrdet av objektet. NÀr resursen har laddats ner sparar vi den i objektet Imageför snabb Ätkomst i framtiden. NÀr nedladdningen av varje enskild resurs Àr tillÄten (det vill sÀga resurserna har laddats), tillÄter vi , som kopplar nyckeln av filnamnet till vÀrdet av objektet downloadPromise alla NÀr resurserna har laddats ner kan vi börja rendera. Som nÀmnts tidigare anvÀnder vi HTML5 Canvas.
). VÄrt spel Àr ganska enkelt, sÄ vi behöver bara rita det följande: (<canvas>...
- Bakgrund
- Spelarens skepp
- Andra spelare som Àr i spelet
- Projicerade skott
HÀr Àr viktiga fragment src/client/render.js, som renderar just de fyra punkterna ovan:
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;
// HÀmta grafikkontexten för canvasen
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');
// Gör canvasen helskÀrms
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
function render() {
const { me, others, bullets } = getCurrentState();
if (!me) {
return;
}
// Rita bakgrunden
renderBackground(me.x, me.y);
// Rita alla skott
bullets.forEach(renderBullet.bind(null, me));
// Rita alla spelare
renderPlayer(me, me);
others.forEach(renderPlayer.bind(null, me));
}
// ... HjÀlpfunktioner hÀr uteslutna
let renderInterval = null;
export function startRendering() {
renderInterval = setInterval(render, 1000 / 60);
}
export function stopRendering() {
clearInterval(renderInterval);
}Denna kod har ocksÄ förkortats för tydlighet.
render() Àr huvudfunktionen i denna fil. startRendering() och stopRendering() hanterar aktiveringen av renderingsloopen med en frekvens av 60 FPS.
Specifika implementationer av individuella hjÀlpfunktioner för rendering (t.ex. renderBullet()) Àr kanske inte sÄ viktiga, men hÀr Àr ett enkelt exempel:
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,
);
} Observera att vi anvÀnder metoden getAsset(), som vi tidigare sett i asset.js!
Om du Àr intresserad av att utforska andra hjÀlpfunktioner för rendering, lÀs resten av .
6. Klientinmatning
Det Àr dags att göra spelet spelbart! Kontrollschemat kommer att vara mycket enkelt: du kan anvÀnda musen (pÄ datorn) eller beröra skÀrmen (pÄ mobilen) för att Àndra rörelseriktningen. För att genomföra detta kommer vi att registrera för mus- och touchhÀndelser.
Allt detta kommer att hanteras av 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() och onTouchInput() Àr Event Listeners som anropas , sÄ att andra filer kan anvÀnda dem. (frÄn networking.js) vid inmatningsevent (t.ex. vid musrörelse). , sÄ att andra filer kan anvÀnda dem. hanter kommunicerar med servern som behandlar inmatningshÀndelsen och uppdaterar spelets tillstÄnd dÀrefter.
7. Klientens tillstÄnd
Detta avsnitt Àr det mest utmanande i den första delen av inlÀgget. Bli inte nedslagen om du inte förstÄr det vid första genomlÀsningen! Du kan till och med hoppa över det och Äterkomma senare.
Den sista pusselbiten som behövs för att fÀrdigstÀlla klient-server-koden Àr state. Kommer du ihÄg kodfragmentet frÄn avsnittet "Rendering av klienten"?
render.js
import { getCurrentState } from './state';
function render() {
const { me, others, bullets } = getCurrentState();
// Gör rendering
// ...
} getCurrentState() mÄste kunna ge oss det aktuella spelets tillstÄnd pÄ klienten vid varje given tidpunkt baserat pÄ uppdateringar som tas emot frÄn servern. HÀr Àr ett exempel pÄ en speluppdatering som servern kan skicka:
{
"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
}
]
}Varje speluppdatering innehÄller fem identiska fÀlt:
- t: serverns tidsstÀmpel som anger nÀr denna uppdatering skapades.
- me: information om spelaren som tar emot denna uppdatering.
- others: en array med information om andra spelare som deltar i samma spel.
- bullets: en array med information om projektiler i spelet.
- leaderboard: aktuell information om poÀngtavlan. Vi kommer inte att beakta dem i detta inlÀgg.
7.1 Naivt klienttillstÄnd
Naiv implementering getCurrentState() kan bara direkt returnera data för den senaste mottagna speluppdateringen.
naive-state.js
let lastGameUpdate = null;
// Hantera en ny mottagen speluppdatering.
export function processGameUpdate(update) {
lastGameUpdate = update;
}
export function getCurrentState() {
return lastGameUpdate;
}Snyggt och klart! Men om det bara vore sÄ enkelt. En av anledningarna till att en sÄdan implementering Àr problematisk Àr: den begrÀnsar renderingens bildfrekvens till serverns klockfrekvens.
Bildfrekvens (Frame Rate): antalet bilder (dvs. anrop
render()) per sekund, eller FPS. I spel strÀvar man vanligtvis efter att nÄ minst 60 FPS.
Klockfrekvens (Tick Rate): frekvensen med vilken servern skickar speluppdateringar till klienterna. Den Àr ofta lÀgre Àn bildfrekvensen. I vÄrt spel fungerar servern med en takt pÄ 30 ticks per sekund.
Om vi bara renderar den senaste uppdateringen av spelet kommer FPS i princip aldrig att kunna övertrĂ€ffa 30, eftersom vi aldrig fĂ„r mer Ă€n 30 uppdateringar per sekund frĂ„n servern.. Ăven om vi anropar render() 60 gĂ„nger per sekund, sĂ„ kommer hĂ€lften av dessa anrop helt enkelt rendera samma innehĂ„ll och i princip inte göra nĂ„got. Ett annat problem med den naive implementeringen Ă€r att den Ă€r utsatt för fördröjningar.Vid idealisk internethastighet kommer klienten att fĂ„ speluppdateringen exakt var 33 ms (30 gĂ„nger per sekund):

TyvÀrr Àr ingenting perfekt. En mer realistisk bild skulle vara:

Den naive implementeringen Àr praktiskt taget det sÀmsta fallet nÀr det kommer till fördröjningar. Om speluppdateringen tas emot med en fördröjning pÄ 50 ms, sÄ försöker klienten 20 gÄnger lÀngre, eftersom den fortfarande renderar spelstatus frÄn den tidigare uppdateringen. Ni kan förestÀlla er hur obehagligt det Àr för spelaren: pÄ grund av slumpmÀssiga fördröjningar kommer spelet att verka hackigt och instabilt.
7.2 FörbÀttrad klientsituation
Vi kommer att göra vissa förbÀttringar i den naive implementeringen. För det första anvÀnder vi renderingsfördröjning pÄ 100 ms. Detta innebÀr att klientens 'aktuella' tillstÄnd alltid kommer att vara 100 ms efter serverns speltillstÄnd. Till exempel, om serverns tid Àr 150, sÄ kommer klienten att rendera ett tillstÄnd dÀr servern var vid 50:

Detta ger oss en buffert pÄ 100 ms, vilket tillÄter oss att hantera oförutsÀgbara vÀntetider för speluppdateringar:

Priset för detta kommer att vara en konstant pĂ„ 100 ms. Detta Ă€r en obetydlig uppoffring för en smidig spelupplevelse â de flesta spelare (sĂ€rskilt casuals) kommer knappt att mĂ€rka denna fördröjning. Det Ă€r mycket lĂ€ttare för mĂ€nniskor att anpassa sig till en konstant fördröjning pĂ„ 100 ms Ă€n att spela med oförutsĂ€gbara fördröjningar.
Vi kan ocksÄ anvÀnda en annan teknik som kallas , som hanterar att minska upplevda fördröjningar bra, men detta kommer inte att behandlas i detta inlÀgg.
En annan förbÀttring som vi anvÀnder Àr linjÀr interpolering.PÄ grund av renderingsfördröjningen ligger vi vanligtvis minst en uppdatering före den aktuella tiden pÄ klienten. NÀr vi anropar getCurrentState(), kan vi utföra mellan speluppdateringar direkt före och efter den aktuella tiden i klienten:

Detta löser problemet med bildfrekvens: nu kan vi rendera unika bilder med vilken frekvens vi behöver!
7.3 Implementation av förbÀttrat klienttillstÄnd
Exempel pÄ implementation i src/client/state.js anvÀnder bÄde renderingsfördröjning och linjÀr interpolation, men det Àr övergÄende. LÄt oss dela upp koden i tvÄ delar. HÀr Àr den första:
state.js, del 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);
// BehÄll endast en speluppdatering före den aktuella server tiden
const base = getBaseUpdate();
if (base > 0) {
gameUpdates.splice(0, base);
}
}
function currentServerTime() {
return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY;
}
// Returnerar indexet för basuppdateringen, den första speluppdateringen före
// aktuell server tid, eller -1 om 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;
} Först mÄste vi förstÄ vad currentServerTime()gör. Som vi sÄg tidigare inkluderar varje speluppdatering serverns tidsstÀmpel. Vi vill anvÀnda renderingsfördröjning för att rendera bilden, med 100 ms fördröjning frÄn servern, men vi kommer aldrig fÄ veta vad den aktuella tiden pÄ servern Àr, eftersom vi inte kan veta hur lÀnge det har tagit för oss att ta emot nÄgon av uppdateringarna. Internet Àr oförutsÀgbart och dess hastighet kan variera kraftigt!
För att komma runt detta problem kan vi anvÀnda en rimlig approximation: vi lÄtsas att den första uppdateringen kom omedelbart. Om det var sant skulle vi veta serverns tid vid det specifika tillfÀllet! Vi sparar serverns tidsstÀmpel i firstServerTimestamp och sparar vÄr lokala (klient) tidsstÀmpel vid samma ögonblick i gameStart.
Oj, vÀnta nu. Borde inte serverns tid = klientens tid? Varför skiljer vi pÄ "serverns tidsstÀmpel" och "klientens tidsstÀmpel"? Det Àr en bra frÄga! Det visar sig att det inte Àr samma sak. Date.now() kommer att ÄtervÀnda olika tidsstÀmplar i klienten och servern och detta beror pÄ lokala faktorer för dessa maskiner. Anta aldrig att tidsstÀmplarna kommer att vara lika pÄ alla maskiner.
Nu förstÄr vi vad currentServerTime()gör: den returnerar serverns tidsstÀmpel för aktuell renderingstid. Med andra ord Àr detta serverns aktuella tid (firstServerTimestamp < + (Date.now() - gameStart)) minus renderingsförseningen (RENDER_DELAY).
LÄt oss nu titta pÄ hur vi hanterar speluppdateringar. NÀr en uppdatering tas emot frÄn servern anropas onGameOver(), och vi sparar den nya uppdateringen i arrayen gameUpdates. För att kontrollera minnesanvÀndningen tar vi bort alla gamla uppdateringar till basuppdateringen, eftersom vi inte lÀngre behöver dem.
Vad Àr dÄ en "basuppdatering"? Det Àr den första uppdateringen som vi hittar nÀr vi rör oss bakÄt frÄn serverns aktuella tid. Kommer ni ihÄg detta schema?

Speluppdateringen direkt till vÀnster om "Client Render Time" Àr basuppdateringen.
Vad anvÀnds basuppdateringen till? Varför kan vi överge uppdateringar fram till basuppdateringen? För att förstÄ detta, lÄt oss Àntligen titta pÄ implementationen getCurrentState():
state.js, del 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),
};
}
}Vi hanterar tre fall:
base < 0betyder att det inte finns nÄgra uppdateringar före den aktuella renderingtiden (se ovanstÄende implementation avgetBaseUpdate()). Detta kan hÀnda i början av spelet pÄ grund av renderingsfördröjning. I sÄ fall anvÀnder vi den senaste mottagna uppdateringen.baseÀr den senaste uppdateringen vi har. Detta kan intrÀffa pÄ grund av nÀtverksfördröjning eller dÄlig internetuppkoppling. I det fallet anvÀnder vi ocksÄ den senaste uppdateringen vi har.- Vi har uppdateringar bÄde före och efter den aktuella renderingtiden, sÄ vi kan interpolera!
Allt som ÄterstÄr i state.js Àr implementationen av linjÀr interpolation, som bestÄr av enkel (men trÄkig) matematik. Om du vill studera den sjÀlv kan du öppna state.js till .
Del 2. Backend-server
I den hÀr delen kommer vi att titta pÄ backend Node.js som hanterar vÄrt .
1. IngÄngspunkt för servern
För att hantera webbservern kommer vi att anvÀnda det populÀra webbramverket för Node.js som heter . Dess instÀllning kommer att hanteras av vÄr ingÄngspunkt för servern src/server/server.js:
server.js, del 1
const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackConfig = require('../..//webpack.dev.js');
// StÀll in en Express-server
const app = express();
app.use(express.static('public'));
if (process.env.NODE_ENV === 'development') {
// StÀll in Webpack för utveckling
const compiler = webpack(webpackConfig);
app.use(webpackDevMiddleware(compiler));
} else {
// Statisk servera mappen dist/ i produktion
app.use(express.static('dist'));
}
// Lyssna pÄ port
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Server lyssnar pÄ port ${port}`);Kom ihÄg att i den första delen diskuterade vi Webpack? HÀr kommer vi att anvÀnda vÄra Webpack-konfigurationer. Vi kommer att tillÀmpa dem pÄ tvÄ sÀtt:
- AnvÀnd för att automatiskt ombygga vÄra utvecklingspaket, eller
- Statiskt överföra mappen
dist/, dÀr Webpack kommer att skriva vÄra filer efter produktionens byggnad.
En annan viktig uppgift server.js Àr att stÀlla in servern , som enkelt ansluter till Express-servern:
server.js, del 2
const socketio = require('socket.io');
const Constants = require('../shared/constants');
// StÀll in Express
// ...
const server = app.listen(port);
console.log(`Server lyssnar pÄ port ${port}`);
// StÀll in socket.io
const io = socketio(server);
// Lyssna efter socket.io-anslutningar
io.on('connection', socket => {
console.log('Spelare ansluten!', socket.id);
socket.on(Constants.MSG_TYPES.JOIN_GAME, joinGame);
socket.on(Constants.MSG_TYPES.INPUT, handleInput);
socket.on('disconnect', onDisconnect);
}); Efter en framgÄngsrik anslutning av socket.io till servern stÀller vi in nÄgra hÀndelsehanterare för den nya socketen. HÀndelsehanterarna hanterar meddelanden som tas emot frÄn klienterna genom att delegera dem till singeltonobjektet game:
server.js, del 3
const Game = require('./game');
// ...
// StÀll in spelet
const game = new Game();
function joinGame(username) {
game.addPlayer(this, username);
}
function handleInput(dir) {
game.handleInput(this, dir);
}
function onDisconnect() {
game.removePlayer(this);
} Vi skapar ett .io-spel, sĂ„ vi behöver bara en instans av Game ('Game') â alla spelare spelar pĂ„ en och samma arena! I nĂ€sta avsnitt kommer vi att titta pĂ„ hur denna klass fungerar. Game.
2. Game-servern
Klassen Game innehÄller den mest kritiska logiken pÄ serversidan. Den har tvÄ huvuduppgifter: hantera spelare och simulera spelet.
LĂ„t oss börja med den första uppgiften â att hantera spelare.
game.js, del 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;
\/\/ Generera en position för att starta denna spelare pÄ.
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);
}
}
\/\/ ...
} I det hÀr spelet kommer vi att identifiera spelarna genom fÀltet id deras socket.io (om du Àr förvirrad, gÄ tillbaka till server.js). Socket.io tilldelar automatiskt varje socket en unik id, sÄ vi behöver inte oroa oss för det. Jag kommer att kalla det spelare-ID.
Kom ihÄg detta, lÄt oss utforska instansvariablerna i klassen Game:
socketsâ detta Ă€r ett objekt som knyter spelarens ID till den socket som Ă€r kopplad till spelaren. Det gör att vi kan fĂ„ Ă„tkomst till sockets efter deras spelar-ID i konstant tid.playersâ detta Ă€r ett objekt som binder spelarens ID till objektet code>Player
bullets â detta Ă€r en array av objekt Bullet, utan nĂ„gon specifik ordning.
lastUpdateTime â detta Ă€r en tidsstĂ€mpel för det senaste uppdateringstillfĂ€llet av spelet. Snart kommer vi att se hur den anvĂ€nds.
shouldSendUpdate â detta Ă€r en hjĂ€lpfunktion. Vi kommer ocksĂ„ att se anvĂ€ndningen av den snart.
Metoder addPlayer(), removePlayer() och handleInput() behöver ingen förklaring, de anvÀnds i server.js. Om du behöver frÀscha upp minnet, gÄ tillbaka lite.
Den sista raden constructor() startar spelets uppdateringscykel (i en frekvens av 60 uppdateringar/s):
game.js, del 2
const Constants = require('..\/shared\/constants');
const applyCollisions = require('.\/collisions');
class Game {
\/\/ ...
update() {
\/\/ BerÀkna förfluten tid
const now = Date.now();
const dt = (now - this.lastUpdateTime) \/ 1000;
this.lastUpdateTime = now;
\/\/ Uppdatera varje projektil
const bulletsToRemove = [];
this.bullets.forEach(bullet => {
if (bullet.update(dt)) {
\/\/ Förstöra denna projektil
bulletsToRemove.push(bullet);
}
});
this.bullets = this.bullets.filter(
bullet => !bulletsToRemove.includes(bullet),
);
\/\/ Uppdatera varje spelare
Object.keys(this.sockets).forEach(playerID => {
const player = this.players[playerID];
const newBullet = player.update(dt);
if (newBullet) {
this.bullets.push(newBullet);
}
});
\/\/ TillÀmpa kollisioner, ge spelarna poÀng för att trÀffa projektiler
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),
);
\/\/ Kontrollera om nÄgon spelare Àr död
Object.keys(this.sockets).forEach(playerID => {
const socket = this.sockets[playerID];
const player = this.players[playerID];
if (player.hp {
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;
}
}
\/\/ ...
} Metod update() innehÄller förmodligen den viktigaste delen av logiken pÄ serversidan. LÄt oss gÄ igenom allt det gör i ordning:
- BerÀknar hur mycket tid
dtsom har gÄtt sedan den senasteupdate(). - Uppdaterar varje projektil och förstör dem om det behövs. Vi kommer att se implementeringen av denna funktionalitet senare. För nu rÀcker det att veta att
bullet.update()Ă„tervĂ€ndertrue, om projektilen ska förstöras (den har lĂ€mnat arenan). - Uppdaterar varje spelare och skapar en projektil om det behövs. Vi kommer ocksĂ„ att se denna implementering senare â
player.update()kan returnera ett objektBullet. - Kontrollerar kollisioner mellan projektiler och spelare med hjÀlp av
applyCollisions(), som returnerar en array av projektiler som trÀffade spelare. För varje returnerad projektil ökar vi poÀngen för spelaren som avfyrade den (med hjÀlp avplayer.onDealtDamage()), och tar sedan bort projektilen frÄn arrayenbullets. - Notifierar och förstör alla dödade spelare.
- Skickar en speluppdatering till alla spelare varannan gÄng nÀr den anropas
update(). Detta hjÀlper oss att spÄra den ovan nÀmnda hjÀlpvÀrdenshouldSendUpdate. Eftersomupdate()det kallas 60 gÄnger/s, vi skickar speluppdateringar 30 gÄnger/s. PÄ sÄ sÀtt, klockfrekvens servern Àr 30 takter/s (vi talade om klockfrekvenser i del ett).
Varför skicka speluppdateringar endast varannan gÄng ? För att spara bandbredd. 30 speluppdateringar per sekund Àr vÀldigt mycket!
Varför inte bara anropa
update()30 gÄnger i sekunden? För att förbÀttra spelens simulering. Ju oftare det anropasupdate(), desto mer exakt blir spelens simulering. Men man ska inte gÄ överstyr med antalet anrop,update()eftersom det Àr en berÀkningsintensiv uppgift - 60 per sekund Àr fullt tillrÀckligt.
Den resterande delen av klassen Game bestÄr av hjÀlpfunktioner som anvÀnds i update():
game.js, del 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() Ă€r ganska enkel â den sorterar spelare efter poĂ€ng, tar de fem bĂ€sta och returnerar anvĂ€ndarnamn och resultat för var och en.
createUpdate() anvĂ€nds i update() för att skapa speluppdateringar som skickas till spelarna. Dess huvudsakliga uppgift Ă€r att anropa metoderna serializeForUpdate(), implementerade för klasserna Player och Bullet. Observera att den endast skickar data om nĂ€ra spelare och kulor â det finns ingen anledning att skicka information om spelobjekt som ligger lĂ„ngt borta frĂ„n spelaren!
3. Spelobjekt pÄ servern
I vÄrt spel liknar kulor och spelare varandra mycket: de Àr abstrakta, runda, rörliga spelobjekt. För att dra nytta av denna likhet mellan spelare och kulor, lÄt oss börja med att implementera en grundklass 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,
};
}
} HÀr hÀnder inget komplicerat. Denna klass blir en bra grund för vidareutveckling. LÄt oss se hur klassen Bullet anvÀnder 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 Constants.MAP_SIZE || this.y Constants.MAP_SIZE;
}
} Implementering Bullet Àr mycket kort! Vi har lagt till Object endast följande tillÀgg:
- AnvÀndningen av paketet för att generera slumpmÀssigt
idprojektiler. - TillÀgg av fÀltet
parentID, för att kunna spÄra spelaren som skapade denna projektil. - TillÀgg av ett returvÀrde i
update(), som Àrtrue, om projektilen Àr uteanför arenan (kom ihÄg att vi pratade om detta i föregÄende avsnitt?).
LÄt oss gÄ vidare till 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,
};
}
} Spelare Àr mer komplexa Àn projektiler, sÄ den hÀr klassen mÄste ha nÄgra fler fÀlt. Dess metod update() gör mer jobb, sÀrskilt nÀr den returnerar den nyss skapade projektilen, om det inte finns nÄgon kvar av fireCooldown (kom ihÄg att vi pratade om detta i föregÄende avsnitt?). Den utökar ocksÄ metoden serializeForUpdate(), eftersom vi behöver inkludera ytterligare fÀlt för spelaren i speluppdateringen.
Att ha en basklass Object Àr ett viktigt steg för att undvika kodupprepning. Till exempel, utan klassen Object mÄste varje spelobjekt ha samma implementation av distanceTo(), och synkroniseringen av att kopiera och klistra in alla dessa implementationer i flera filer skulle vara en mardröm. Detta blir sÀrskilt viktigt för stora projekt, nÀr antalet utvidgande Object klasser ökar.
4. Kollisiondetektering
Det enda vi har kvar Àr att kÀnna igen nÀr projektiler trÀffar spelarna! Kom ihÄg detta kodavsnitt frÄn metoden update() i klassen Game:
game.js
const applyCollisions = require('./collisions');
class Game {
// ...
update() {
// ...
// TillÀmpa kollisioner, ge spelare poÀng för att trÀffa projektile
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),
);
// ...
}
} Vi behöver implementera metoden applyCollisions(), som returnerar alla projektiler som trÀffar spelarna. Lyckligtvis Àr det inte sÄ svÄrt att göra, eftersom
- Alla kolliderande objekt Àr cirklar, vilket Àr den enklaste formen för att implementera kollisioner.
- Vi har redan en metod
distanceTo(), som vi implementerade i föregÄende avsnitt i klassenObject.
SÄ hÀr ser vÄr implementation av kollisionerna ut:
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;
}Denna enkla kollisiondetektering Àr baserad pÄ att tvÄ cirklar kolliderar om avstÄndet mellan deras centra Àr mindre Àn summan av deras radier.HÀr Àr ett fall dÀr avstÄndet mellan centren av de tvÄ cirklarna exakt Àr summan av deras radier:

HÀr behöver vi vara uppmÀrksamma pÄ ett par aspekter till:
- En projektil fÄr inte trÀffa den spelare som skapade den. Detta kan uppnÄs genom att jÀmföra
bullet.parentIDmedplayer.id. - En projektil ska trÀffa endast en gÄng i ett grÀnsfall av samtidig kollision med flera spelare. Vi kommer att lösa denna uppgift med hjÀlp av operatorn
break: sÄ snart en spelare som kolliderar med projektilen hittas, avbryter vi sökningen och gÄr vidare till nÀsta projektil.
Slut
Det var allt! Vi har gÄtt igenom allt som behövs för att skapa ett webspel av typen .io. Vad blir nÀsta steg? Bygg ditt eget .io-spel!
All kod för exemplet har öppen kÀllkod och finns pÄ .
KĂ€lla: habr.com
