
Utgitt i 2015 ble stamfar til en ny sjanger , som har vokst i popularitet siden den gang. Jeg har opplevd fremveksten av .io-spill pÄ nÊrt hold: i lÞpet av de siste tre Ärene har jeg .
Hvis du aldri har hĂžrt om dem fĂžr, er de gratis, nettbaserte flerspillerspill som er enkle Ă„ spille (ingen konto kreves). De setter vanligvis flere motspillere opp mot hverandre i en arena. Andre kjente .io-spill inkluderer: Đž .
I dette innlegget skal vi finne ut hvordan lag et .io-spill fra bunnen avAlt du trenger Ä vite er Javascript: du mÄ forstÄ ting som syntaks , nÞkkelord this О Selv om du ikke kan Javascript perfekt, vil du fortsatt kunne forstÄ mesteparten av dette innlegget.
Eksempel pÄ .io-spill
For Ă„ hjelpe deg med lĂŠringen, vil vi henvise til PrĂžv Ă„ spille det!

Spillet er ganske enkelt: du kontrollerer et skip i en arena der det er andre spillere. Skipet ditt avfyrer automatisk prosjektiler, og du prÞver Ä treffe de andre spillerne mens du unngÄr prosjektilene deres.
1. Prosjektoversikt/-struktur
anbefale eksempelspill slik at du kan fĂžlge med meg.
Eksemplet bruker fĂžlgende:
- â det mest populĂŠre webrammeverket for Node.js, som kjĂžrer spillets webserver.
- â websocket-bibliotek for utveksling av data mellom nettleseren og serveren.
- â modulbehandler. Du kan lese om hvorfor du bĂžr bruke Webpack .
Slik ser prosjektets katalogstruktur ut:
offentlig/ressurser/ ... src/klient/css/ ... html/index.html index.js ... server/server.js ... delt/konstanter.js
offentlig/
Alt er i mappen public/ vil bli statisk overfÞrt av serveren. public/assets/ inneholder bilder brukt i prosjektet vÄrt.
src /
All kildekoden er i mappen src/. Titler client/ О server/ snakke for seg selv, og shared/ inneholder en konstantfil importert av bÄde klienten og serveren.
2. Samlinger/prosjektparametere
Som nevnt ovenfor bruker vi modulbehandleren til Ä bygge prosjektet. La oss ta en titt pÄ Webpack-konfigurasjonen vÄr:
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 viktigste linjene her er:
src/client/index.jsâ Dette er inngangspunktet for Javascript (JS)-klienten. Webpack starter herfra og sĂžker rekursivt etter andre importerte filer.- Utdata-JS-filen fra Webpack-bygget vĂ„rt vil bli plassert i katalogen
dist/Jeg vil kalle denne filen vÄr JS-pakke. - Vi bruker , og spesielt konfigurasjonen Ä transpilere JS-koden vÄr for eldre nettlesere.
- Vi bruker en plugin for Ä trekke ut all CSS-en som JS-filene refererer til og kombinere den pÄ ett sted. Jeg kaller den vÄr CSS-pakke.
Du har kanskje lagt merke til merkelige navn pĂ„ pakkefiler. '[name].[contenthash].ext'. De inneholder Nettpakke: [name] vil bli erstattet av navnet pĂ„ inngangspunktet (i vĂ„rt tilfelle er det game), a [contenthash] vil bli erstattet med hashen til filinnholdet. Vi gjĂžr dette slik at â vi kan fortelle nettlesere at de skal mellomlagre JS-pakkene vĂ„re pĂ„ ubestemt tid fordi Hvis en pakke endres, endres ogsĂ„ filnavnet (endringer contenthash). Det ferdige resultatet vil vĂŠre et filnavn pĂ„ skjemaet game.dbeee76e91a97d0c7207.js.
fil webpack.common.js â dette er den grunnleggende konfigurasjonsfilen som vi importerer til utviklings- og ferdige prosjektkonfigurasjoner. Her er for eksempel utviklingskonfigurasjonen:
webpack.dev.js
const merge = require('webpack-merge');
const common = require('./webpack.common.js');
module.exports = merge(common, {
mode: 'development',
}); For effektivitets skyld bruker vi i utviklingsprosessen webpack.dev.js, og bytter til webpack.prod.jsfor Ă„ optimalisere pakkestĂžrrelser ved distribusjon til produksjon.
Lokalt oppsett
Jeg anbefaler Ä installere prosjektet pÄ en lokal maskin, slik at du kan fÞlge trinnene som er oppfÞrt i dette innlegget. Oppsettet er enkelt: fÞrst mÄ du ha О . Det neste du mÄ gjÞre
$ git clone https://github.com/vzhou842/example-.io-game.git
$ cd example-.io-game
$ npm installog du er klar! For Ă„ starte utviklingsserveren, kjĂžr ganske enkelt
$ npm run developog gĂ„ til nettleseren pĂ„ Utviklingsserveren vil automatisk gjenoppbygge JS- og CSS-pakker nĂ„r du endrer koden din â bare oppdater siden for Ă„ se alle endringene!
3. Klientinngangspunkter
La oss begynne med selve spillkoden. FÞrst trenger vi en side index.html, nÄr du besÞker nettstedet, vil nettleseren laste det inn fÞrst. Siden vÄr vil vÊre ganske enkel:
index.html
Et eksempel pÄ et .io-spill SPILLE
Dette kodeeksemplet er litt forenklet for klarhetens skyld, og jeg vil gjÞre det samme med mange andre eksempler i innlegget. Hele koden kan alltid sees pÄ .
Vi har:
- (
<canvas>), som vi skal bruke til Ä rendre spillet. <link>for Ä legge til CSS-pakken vÄr.<script>for Ä legge til Javascript-pakken vÄr.- Hovedmeny med brukernavn
<input>og «SPILL AV»-knappen (<button>).
NÄr hjemmesiden er lastet inn, vil nettleseren begynne Ä kjÞre Javascript-kode, og starte med JS-filen for inngangspunktet: 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);
};
});Dette hÞres kanskje komplisert ut, men det er egentlig ikke sÄ mye som skjer her:
- Importerer noen andre JS-filer.
- Importer CSS (slik at Webpack vet at de skal inkluderes i CSS-pakken vÄr).
- lanseringen
connect()for Ă„ opprette en forbindelse til serveren og startedownloadAssets()for Ă„ laste ned bilder som trengs for Ă„ rendre spillet. - Etter Ă„ ha fullfĂžrt trinn 3 hovedmenyen vises (
playMenu). - Setter opp klikkhÄndtereren for «SPILL»-knappen. NÄr knappen klikkes, initialiserer koden spillet og forteller serveren at vi er klare til Ä spille.
Hoved"kjerne" i klient-server-logikken vÄr ligger i filene som ble importert av filen index.jsNÄ skal vi vurdere dem alle i rekkefÞlge.
4. Utveksling av kundedata
I dette spillet bruker vi et velkjent bibliotek for Ă„ kommunisere med serveren. Socket.io har innebygd stĂžtte , som er godt egnet for toveiskommunikasjon: vi kan sende meldinger til serveren Đž Serveren kan sende meldinger til oss over den samme forbindelsen.
Vi vil ha én fil src/client/networking.js, hvem skal ta seg av av alle kommunikasjon med serveren:
nettverk.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);
};Denne koden er ogsÄ litt forkortet for klarhetens skyld.
Det er tre hovedhandlinger som utfĂžres i denne filen:
- Vi prĂžver Ă„ koble til serveren.
connectedPromiseer kun tillatt nÄr vi har opprettet en forbindelse. - Hvis forbindelsen er opprettet, registrerer vi tilbakeringingsfunksjoner (
processGameUpdate()ĐžonGameOver()) for meldinger vi kan motta fra serveren. - Vi eksporterer
play()ĐžupdateDirection()slik at andre filer kan bruke dem.
5. Klientgjengivelse
Det er pÄ tide Ä vise bildet pÄ skjermen!
...men fÞr vi kan gjÞre det, mÄ vi laste ned alle bildene (ressursene) som trengs for Ä gjÞre det. La oss skrive en ressursbehandler:
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]; RessurshÄndtering er ikke sÄ vanskelig Ä implementere! Hovedideen er Ä lagre et objekt assets, som vil binde filnavnnÞkkelen til objektverdien ImageNÄr ressursen er lastet inn, lagrer vi den til et objekt. assets for rask henting i fremtiden. NÄr nedlasting av hver enkelt ressurs er tillatt (dvs. vil bli lastet ned alle ressurser), tillater vi downloadPromise.
NÄr ressursene er nedlastet, kan vi begynne Ä gjengi. Som nevnt tidligere bruker vi (<canvas>). Spillet vÄrt er ganske enkelt, sÄ vi trenger bare Ä tegne fÞlgende:
- bakgrunn
- Spillerens skip
- Andre spillere i spillet
- Skjell
Her er de viktige fragmentene src/client/render.js, som tegner nĂžyaktig de fire punktene som er oppfĂžrt ovenfor:
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);
}Denne koden er ogsÄ forkortet for klarhetens skyld.
render() â hovedfunksjonen til denne filen. startRendering() Đž stopRendering() kontrollere aktiveringen av 60 FPS-gjengivelsessyklusen.
Konkrete implementeringer av individuelle gjengivelseshjelpefunksjoner (f.eks. renderBullet()) er ikke sÄ viktige, men her er et enkelt eksempel:
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,
);
} Merk at vi bruker metoden getAsset(), som tidligere ble sett i asset.js!
Hvis du er interessert i Ă„ lĂŠre flere hjelpefunksjoner for gjengivelse, kan du lese resten av denne artikkelen. .
6. Klientinnspill
Det er pÄ tide Ä lage et spill spillbarKontrollskjemaet vil vÊre veldig enkelt: for Ä endre bevegelsesretningen kan du bruke musen (pÄ en datamaskin) eller berÞre skjermen (pÄ en mobil enhet). For Ä implementere dette vil vi registrere for mus- og berÞringshendelser.
Alt dette vil bli tatt hÄnd om 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() â dette er hendelseslyttere som kaller updateDirection() (av networking.js) nĂ„r en input-hendelse oppstĂ„r (for eksempel nĂ„r musen beveges). updateDirection() hĂ„ndterer utvekslingen av meldinger med serveren, som behandler input-hendelsen og oppdaterer spillstatusen deretter.
7. Klientstatus
Denne delen er den vanskeligste i den fÞrste delen av innlegget. Ikke bli lei deg hvis du ikke forstÄr den fÞrste gang du leser den! Du kan til og med hoppe over den og komme tilbake til den senere.
Den siste brikken i puslespillet som trengs for Ä fullfÞre klient-server-koden er statHusker du kodebiten fra delen «Klientgjengivelse»?
render.js
import { getCurrentState } from './state';
function render() {
const { me, others, bullets } = getCurrentState();
// Do the rendering
// ...
} getCurrentState() burde kunne gi oss den nÄvÊrende tilstanden til spillet i klienten nÄr som helst basert pÄ oppdateringer mottatt fra serveren. Her er et eksempel pÄ en spilloppdatering som serveren kan sende:
{
"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
}
]
}Hver spilloppdatering inneholder fem identiske felt:
- tServerens tidsstempel som angir nÄr denne oppdateringen ble opprettet.
- meInformasjon om spilleren som mottar denne oppdateringen.
- andreen rekke informasjon om andre spillere som deltar i det samme spillet.
- kuler: en rekke informasjoner om prosjektiler i spillet.
- leaderboard: nÄvÊrende resultattavledata. Vi tar ikke hensyn til det i dette innlegget.
7.1 Klientens naive tilstand
Naiv implementering getCurrentState() kan bare returnere dataene fra den nyeste spilloppdateringen som ble mottatt direkte.
naive-state.js
let lastGameUpdate = null;
// Handle a newly received game update.
export function processGameUpdate(update) {
lastGameUpdate = update;
}
export function getCurrentState() {
return lastGameUpdate;
}Fint og tydelig! Men om bare alt var sÄ enkelt. En av grunnene til at en slik implementering er problematisk: den begrenser gjengivelsesrammefrekvensen til serverens klokkefrekvens.
Bildefrekvensantall rammer (dvs. anrop
render()) per sekund, eller FPS. Spill tar vanligvis sikte pÄ Ä oppnÄ minst 60 FPS.
Tick Rate: Hyppigheten serveren sender spilloppdateringer til klienter med. Den er ofte lavere enn bildefrekvensen.I spillet vÄrt kjÞrer serveren med 30 ticks per sekund.
Hvis vi bare rendrer den nyeste oppdateringen av spillet, vil FPS-en i hovedsak aldri kunne overstige 30, fordi Vi fÄr aldri mer enn 30 oppdateringer per sekund fra serverenSelv om vi ringer render() 60 ganger per sekund, vil halvparten av disse kallene ganske enkelt tegne det samme pÄ nytt, og i hovedsak ikke gjÞre noe. Et annet problem med den naive implementeringen er at den med forbehold om forsinkelserMed ideell internetthastighet vil klienten motta en spilloppdatering nÞyaktig hver 33 ms (30 per sekund):

Dessverre er ingenting perfekt. Et mer realistisk bilde ville vĂŠrt:

Den naive implementeringen er nesten det verste tilfellet nÄr det gjelder latens. Hvis en spilloppdatering mottas med en forsinkelse pÄ 50 ms, da klienten bremser ned 50 ms ekstra fordi den fortsatt gjengir spillstatusen fra forrige oppdatering. Du kan tenke deg hvor upraktisk dette er for spilleren: spillet vil fÞles hakkete og ustabilt pÄ grunn av tilfeldige nedbremsinger.
7.2 Forbedret klienttilstand
Vi vil gjÞre noen forbedringer i den naive implementeringen. FÞrst bruker vi gjengivelsesforsinkelse med 100 ms. Dette betyr at klientens "nÄvÊrende" tilstand alltid vil vÊre 100 ms bak spilltilstanden pÄ serveren. Hvis for eksempel tiden pÄ serveren er 150, sÄ vil klienten gjengi tilstanden serveren var i pÄ det tidspunktet 50:

Dette gir oss en buffer pÄ 100 ms for Ä overleve den uforutsigbare timingen av spilloppdateringer:

Prisen Ă„ betale for dette vil vĂŠre konstant med 100 ms. Dette er et lite offer for jevn spilling â de fleste spillere (spesielt de som spiller avslappet) vil ikke engang merke denne forsinkelsen. Folk har mye lettere for Ă„ tilpasse seg en konstant forsinkelse pĂ„ 100 ms enn Ă„ spille med uforutsigbar forsinkelse.
Vi kan ogsÄ bruke en annen teknikk som kalles , som gjÞr en god jobb med Ä redusere opplevd latens, men som ikke vil bli dekket i dette innlegget.
En annen forbedring vi bruker er lineÊr interpolasjonPÄ grunn av gjengivelsesforsinkelsen er vi vanligvis minst én oppdatering foran gjeldende tidspunkt pÄ klienten. NÄr getCurrentState(), vi kan gjÞre det mellom spilloppdateringer rett fÞr og etter gjeldende tidspunkt i klienten:

Dette lÞser problemet med bildefrekvens: nÄ kan vi gjengi unike bilder i hvilken som helst hastighet vi Þnsker!
7.3 Realisering av den forbedrede klienttilstanden
Eksempel pÄ implementering i src/client/state.js bruker bÄde gjengivelsesforsinkelse og lineÊr interpolasjon, men det varer ikke lenge. La oss dele koden inn i to deler. Her er den fÞrste:
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);
// 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;
} Det fÞrste du mÄ gjÞre er Ä finne ut hva den gjÞr currentServerTime()Som vi sÄ tidligere, inkluderer hver spilloppdatering et tidsstempel for serveren. Vi Þnsker Ä bruke gjengivelsesforsinkelse for Ä gjengi bildet 100 ms bak serveren, men Vi vil aldri vite hva klokken er pÄ serveren, fordi vi ikke kan vite hvor lang tid det tok fÞr noen av oppdateringene nÄdde oss. Internett er uforutsigbart, og hastigheten kan variere mye!
For Ä omgÄ dette problemet kan vi bruke en rimelig tilnÊrming: vi La oss late som om den fÞrste oppdateringen kom umiddelbartHvis dette var sant, ville vi visst servertiden pÄ det bestemte tidspunktet! Vi lagrer serverens tidsstempel i firstServerTimestamp og redde vÄre lokal (klient) tidsstempel pÄ samme tidspunkt i gameStart.
Ă
, vent litt. Burde ikke servertid vÊre = klienttid? Hvorfor skiller vi mellom «servertidsstempel» og «klienttidsstempel»? Det er et godt spÞrsmÄl! Det viser seg at de ikke er det samme. Date.now() vil returnere forskjellige tidsstempler pÄ klienten og serveren, og dette avhenger av faktorer lokale for disse maskinene. Ikke anta at tidsstemplene vil vÊre de samme pÄ alle maskiner.
NÄ forstÄr vi hva den gjÞr currentServerTime()han kommer tilbake servertidsstempel for gjeldende gjengivelsestidMed andre ord, dette er gjeldende servertid (firstServerTimestamp <+ (Date.now() - gameStart)) minus gjengivelsesforsinkelse (RENDER_DELAY).
La oss nÄ finne ut hvordan vi hÄndterer spilloppdateringer. NÄr en oppdatering mottas fra serveren, processGameUpdate(), og vi lagrer den nye oppdateringen til arrayet gameUpdatesDeretter, for Ä sjekke minnebruken, sletter vi alle gamle oppdateringer fÞr grunnleggende oppdatering, fordi vi ikke trenger dem lenger.
Hva er en «kjerneoppdatering»? Det er den fÞrste oppdateringen vi finner beveger seg bakover fra gjeldende servertidHusker du dette diagrammet?

Spilloppdateringen rett til venstre for «Client Render Time» er basisoppdateringen.
Hva er hensikten med en kjerneoppdatering? Hvorfor kan vi rulle tilbake oppdateringer til en kjerneoppdatering? For Ä forstÄ dette, la oss endelig La oss se pÄ implementeringen 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 behandler tre saker:
base < 0betyr at det ikke er noen oppdateringer fĂžr gjeldende gjengivelsestidspunkt (se implementering ovenfor)getBaseUpdate()). Dette kan skje rett i starten av spillet pĂ„ grunn av gjengivelsesforsinkelser. I dette tilfellet bruker vi den siste oppdateringen som er mottatt.baseâ Dette er den siste oppdateringen vi har. Dette kan skje pĂ„ grunn av nettverksforsinkelse eller dĂ„rlig internettforbindelse. I dette tilfellet bruker vi ogsĂ„ den siste oppdateringen vi har.- Vi har en oppdatering bĂ„de fĂžr og etter gjeldende gjengivelsestidspunkt, slik at du kan interpolere!
Alt som er igjen i state.js â er en implementering av lineĂŠr interpolasjon, som er enkel (men kjedelig) matematikk. Hvis du vil lĂŠre det selv, sjekk ut state.js pĂ„ .
Del 2. Backend-server
I denne delen skal vi se pÄ Node.js-backend-en som driver vÄr .
1. Serverinngangspunkt
For Ä administrere webserveren bruker vi et populÊrt webrammeverk for Node.js kalt Serverens inngangspunktfil vil hÄndtere konfigurasjonen. 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');
// 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}`);Husker du at vi diskuterte Webpack i del 1? Det er her vi skal bruke Webpack-konfigurasjonene vÄre. Vi skal bruke dem pÄ to mÄter:
- Bruk Ä automatisk gjenoppbygge utviklingspakkene vÄre, eller
- Statisk overfĂžring av mapper
dist/, som Webpack vil skrive filene vÄre inn i etter Ä ha bygget produksjon.
En annen viktig oppgave server.js bestÄr av Ä sette opp serveren , som ganske enkelt kobler seg til Express-serveren:
server.js, del 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);
}); Etter at vi har opprettet en socket.io-tilkobling til serveren, konfigurerer vi hendelseshÄndterere for den nye socketen. HendelseshÄndterere behandler meldinger mottatt fra klienter ved Ä delegere til et singleton-objekt. game:
server.js, del 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);
} Vi lager et .io-spill, sĂ„ vi trenger bare Ă©n instans. Game («Spill») â alle spillere spiller i samme arena! I neste avsnitt skal vi se hvordan denne klassen fungerer. Game.
2. Spillservere
Klasse Game inneholder den viktigste logikken pÄ serversiden. Den har to hovedoppgaver: spilleradministrasjon О spillsimulering.
La oss starte med den fĂžrste oppgaven â spilleradministrasjon.
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;
// 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);
}
}
// ...
} I denne kampen skal vi identifisere spillere etter banen id deres socket.io (hvis du blir forvirret, gÄ tilbake til server.js). Socket.io tildeler selv hver socket en unik id, sÄ vi trenger ikke Ä bekymre oss for det. Jeg ringer ham. Spiller ID.
Med dette i tankene, la oss utforske instansvariabler i en klasse Game:
socketsâ er et objekt som binder en spiller-ID til en socket som er tilknyttet spilleren. Det lar oss fĂ„ tilgang til sockets via spiller-ID-ene deres nĂ„r som helst.playersâ er et objekt som binder spiller-ID-en til koden>Spiller-objektet
bullets - er en rekke objekter Bullet, som ikke har noen spesifikk rekkefĂžlge.
lastUpdateTime â Dette er tidsstempelet for siste gang spillet ble oppdatert. Vi fĂ„r se hvordan det brukes snart.
shouldSendUpdate â er en hjelpevariabel. Vi skal ogsĂ„ se bruken av den snart.
fremgangsmÄter addPlayer(), removePlayer() О handleInput() Trenger ikke Ä forklare, de brukes i server.jsHvis du trenger Ä friske opp hukommelsen, gÄ litt hÞyere tilbake.
Den siste linjen constructor() starter opp oppdateringssyklus spill (med 60 oppdateringsfrekvens/s):
game.js, del 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;
}
}
// ...
} metode update() inneholder sannsynligvis den viktigste delen av serversidelogikken. La oss liste opp alt den gjĂžr i rekkefĂžlge:
- Regner ut hvor mye tid
dtdet har vĂŠrt siden sistupdate(). - Oppdaterer hvert prosjektil og Ăždelegger dem om nĂždvendig. Vi vil se implementeringen av denne funksjonaliteten senere. ForelĂžpig er det nok for oss Ă„ vite at
bullet.update()returnerertrue, hvis prosjektilet mĂ„ Ăždelegges (han gikk utenfor arenagrensene). - Oppdaterer hver spiller og lager et prosjektil om nĂždvendig. Vi fĂ„r se denne implementeringen senere ogsĂ„ â
player.update()kan returnere et objektBullet. - Sjekker for kollisjoner mellom prosjektiler og spillere som bruker
applyCollisions(), som returnerer en rekke prosjektiler som treffer spillere. For hvert prosjektil som returneres, Ăžker vi poengsummen til spilleren som avfyrte det (ved Ă„ brukeplayer.onDealtDamage()), og fjern deretter prosjektilet fra matrisenbullets. - Varsler og Ăždelegger alle drepte spillere.
- Sender en spilloppdatering til alle spillere annenhver én gang per samtale
update()Hjelpevariabelen nevnt ovenfor hjelper oss med Ă„ spore dette.shouldSendUpdate. Somupdate()kalles 60 ganger/sek, sender vi spilloppdateringer 30 ganger/sek. SĂ„, klokkefrekvens serveren er lik 30 sykluser/s (vi snakket om syklusfrekvensen i fĂžrste del).
Hvorfor bare sende spilloppdateringer annenhver gang ? For Ä spare bÄndbredde er 30 spilloppdateringer per sekund mye!
Hvorfor ikke bare ringe da?
update()30 ganger i sekundet? For Ă„ forbedre simuleringen av spillet. Jo oftere det kallesupdate(), desto mer nĂžyaktig blir simuleringen av spillet. Men ikke bli for revet med av antall samtalerupdate(), fordi det er en beregningsmessig dyr oppgave â 60 per sekund er helt nok.
Resten av klassen Game bestÄr av hjelpemetoder som brukes 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) <= 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() Det er ganske enkelt â det sorterer spillere etter poengsum, tar de fem beste og returnerer brukernavn og poengsum for hver av dem.
createUpdate() brukt i update() Ă„ lage spilloppdateringer som leveres til spillere. Hovedoppgaven er Ă„ kalle metoder serializeForUpdate(), implementert for klasser Player Đž BulletMerk at den bare overfĂžrer data til hver spiller omtrent nĂŠrmeste spillere og prosjektiler - det er ikke nĂždvendig Ă„ overfĂžre informasjon om spillobjekter som er langt fra spilleren!
3. Spillobjekter pÄ serveren
I spillet vÄrt er prosjektiler og spillere faktisk veldig like: de er abstrakte, runde, bevegelige spillobjekter. For Ä dra nytte av denne likheten mellom spillere og prosjektiler, la oss begynne med Ä implementere en basisklasse. Object:
objekt.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,
};
}
} Det er ikke noe komplisert som skjer her. Denne klassen vil vÊre et godt utgangspunkt for utvidelse. La oss se hvordan klassen gÄr. Bullet bruker 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;
}
} implementering Bullet veldig kort! Vi la til Object bare fĂžlgende utvidelser:
- Bruk av pakken for tilfeldig generering
idprosjektil. - Legge til et felt
parentID, slik at spilleren som laget prosjektilet kan spores. - Legge til en returverdi til
update(), som er liktrue, hvis prosjektilet er utenfor arenaen (husker du at vi snakket om dette i forrige avsnitt?).
La oss gÄ videre til Player:
spiller.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,
};
}
} Spillere er mer komplekse enn prosjektiler, sÄ denne klassen bÞr lagre noen flere felt. Metoden hans update() gjÞr mer arbeid, spesielt Ä returnere det nyopprettede prosjektilet hvis det ikke er noe igjen fireCooldown (husker du at vi snakket om dette i forrige avsnitt?) Den utvider ogsÄ metoden serializeForUpdate(), fordi vi mÄ inkludere flere felt for spilleren i spilloppdateringen.
TilstedevĂŠrelsen av en basisklasse Object â et viktig skritt for Ă„ unngĂ„ koderepetisjonFor eksempel uten klasse Object Hvert spillobjekt mĂ„ ha samme implementering distanceTo(), og Ă„ synkronisere kopiering og liming av alle disse implementasjonene inn i flere filer ville vĂŠre et mareritt. Dette blir spesielt viktig for store prosjekter., nĂ„r antallet ekspanderende Object klassene vokser.
4. Kollisjonsdeteksjon
Det eneste som gjenstÄr for oss er Ä gjenkjenne nÄr prosjektiler treffer spillere! Husk denne kodebiten fra metoden update() i klassen Game:
spill.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),
);
// ...
}
} Vi mÄ implementere metoden applyCollisions(), som returnerer alle prosjektiler som treffer spillere. Heldigvis er ikke dette sÄ vanskelig Ä gjÞre, fordi
- Alle kolliderende objekter er sirkler, som er den enkleste formen for Ă„ implementere kollisjonsdeteksjon.
- Vi har allerede en metode
distanceTo(), som vi implementerte i klassen i forrige avsnittObject.
Slik ser implementeringen av kollisjonsdeteksjon ut:
kollisjoner.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;
}Denne enkle kollisjonsdeteksjonen er basert pÄ det faktum at to sirkler kolliderer hvis avstanden mellom sentrumene deres er mindre enn summen av radiene deresHer er et tilfelle der avstanden mellom sentrum av to sirkler er nÞyaktig lik summen av radiene deres:

Det er et par flere aspekter som mÄ tas i betraktning her:
- Prosjektilet mÄ ikke treffe spilleren som laget det. Dette kan oppnÄs ved Ä sammenligne
bullet.parentIDŃplayer.id. - Prosjektilet skal bare treffe Ă©n gang i ekstreme tilfeller av samtidig kollisjon med flere spillere. Vi lĂžser dette problemet ved hjelp av operatoren
breakNÄr en spiller som har kollidert med et prosjektil blir funnet, stopper vi sÞket og gÄr videre til neste prosjektil.
end
Det var det! Vi har dekket alt du trenger Ă„ vite for Ă„ lage et .io-nettspill. Hva blir det neste? Bygg ditt eget .io-spill!
All eksempelkoden er Äpen kildekode og er lagt ut pÄ .
Kilde: www.habr.com
