
Udgivet i 2015 blev en forælder til en ny genre , hvis popularitet er steget betydeligt siden da. Jeg har oplevet væksten af populariteten af .io-spil: i løbet af de sidste tre år har jeg .
Hvis du ikke har hørt om sådanne spil før: det er gratis multiplayer-webspil, som er lette at deltage i (ingen konto kræves). De har normalt mange modstridende spillere i en arena. Andre berømte spil i .io-genren: og .
I dette indlæg vil vi se på, hvordan man kan lave et .io-spil fra bunden.For dette er det nok at have kendskab til Javascript: du skal forstå koncepter som syntaks , nøgleordet dette og . Selvom du ikke er en mester i Javascript, vil du stadig kunne forstå det meste af indlægget.
Eksempel på et .io-spil
For at hjælpe med læringen vil vi henvise til Prøv at spille det!

Spillet er ret simpelt: du styrer et skib på en arena, hvor der er andre spillere. Dit skib skyder automatisk projektiler, og du prøver at ramme de andre spillere, samtidig med at du undgår deres projektiler.
1. Kort oversigt/struktur af projektet
Jeg anbefaler til eksemplet på spillet, så du kan følge med mig.
I eksemplet anvendes følgende:
- — den mest populære web-ramme for Node.js, der driver spillets webserver.
- — websocket-bibliotek til dataudveksling mellem browser og server.
- — modulhåndtering. Du kan læse om, hvorfor man skal bruge Webpack .
Sådan ser projektkatalogstrukturen ud:
public/
assets/
...
src/
client/
css/
...
html/
index.html
index.js
...
server/
server.js
...
shared/
constants.jspublic/
Alt i mappen public/ vil blive statisk leveret af serveren. I public/assets/ er de billeder, der bruges af vores projekt, gemt.
src/
Al kildekoden findes i mappen src/. Navnene client/ og server/ taler for sig selv, og shared/ indeholder en konstantfil, der importeres både af klienten og serveren.
2. Bygning/parameter for projektet
Som nævnt tidligere bruger vi modulhåndtereren til at bygge projektet . Lad os tage et kig på vores 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 vigtigste linjer her er følgende:
src/client/index.js— dette er indgangspunktet for Javascript-klienten (JS). Webpack vil starte herfra og søge rekursivt efter andre importerede filer.- Den udgående JS fra vores Webpack-bygning vil være placeret i mappen
dist/. Jeg vil kalde denne fil vores JS-pakke. - Vi bruger , og specifikt konfigurationen til transpiling af vores JS-kode til ældre browsere.
- Vi bruger et plugin til at udtrække al CSS, der refereres i JS-filerne, og til at samle dem ét sted. Jeg vil kalde dette vores CSS-pakke.
Du har måske bemærket de mærkelige filnavne på pakkerne '[name].[contenthash].ext'. De indeholder Webpack: [name] vil blive erstattet med navnet på indgangspunktet (i vores tilfælde er det game), mens [contenthash] vil blive erstattet med en hash af filens indhold. Vi gør dette for at — vi kan bede browsere om konstant at cache vores JS-pakker, fordi hvis pakken ændres, ændres også filens navn (ændrer contenthash). Det færdige resultat vil være et filnavn af typen game.dbeee76e91a97d0c7207.js.
Fil webpack.common.js — det er den grundlæggende konfigurationsfil, som vi importerer i udviklings- og produktionskonfigurationer. Her er for eksempel udviklingskonfigurationen:
webpack.dev.js
const merge = require('webpack-merge');
const common = require('./webpack.common.js');
module.exports = merge(common, {
mode: 'development',
}); For effektivitet bruger vi webpack.dev.jsunder udviklingsprocessen, og skifter til webpack.prod.jsfor at optimere størrelsen af pakkerne ved implementering til produktion.
Lokal opsætning
Jeg anbefaler at installere projektet på din lokale maskine, så du kan følge de trin, der er beskrevet i dette indlæg. Opsætningen er enkel: for det første skal og være installeret i systemet.
$ git clone https://github.com/vzhou842/example-.io-game.git
$ cd example-.io-game
$ npm installer du klar til at komme i gang! For at starte udviklingsserveren skal du blot køre
$ npm run developog åbne i webbrowseren på . Udviklingsserveren vil automatisk genkompilere JS- og CSS-pakkerne, når koden ændres — opdater blot siden for at se alle ændringerne!
3. Klientpunkter
Lad os komme til selve spillets kode. Først har vi brug for en side index.html, som vil blive den første, der indlæses, når siden besøges. Vores side vil være ret simpel:
index.html
<!DOCTYPE html>
<html>
<head>
<title>Et eksempel på et .io-spil</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="Brugernavn" />
<button id="play-button">SPIL</button>
</div>
</body>
</html>Dette kodeeksempel er forenklet for at være lettere at forstå, og jeg vil gøre det samme med mange andre eksempler i indlægget. Den fulde kode kan altid ses på .
Vi har:
- (
<canvas>), som vi vil bruge til at gengive spillet. <link>for at tilføje vores CSS-pakke.<script>for at tilføje vores Javascript-pakke.- Hovedmenu med brugernavn
<input>og en 'PLAY'-knap ().
Når hjemmesiden er indlæst i browseren, vil Javascript-koden begynde at køre fra indgangspunktets JS-fil: 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);
};
});Det kan se kompliceret ud, men der sker faktisk ikke så meget her:
- Import af flere andre JS-filer.
- Import af CSS (så Webpack ved, at den skal inkludere dem i vores CSS-pakke).
- Kørsel
connect()for at oprette en forbindelse til serveren og startedownloadAssets()for at downloade de billeder, der er nødvendige for at gengive spillet. - Når trin 3 er færdig vises hovedmenuen (
playMenu). - Indstil en håndtering til knappen 'PLAY'. Når knappen trykkes, initialiserer koden spillet og informerer serveren om, at vi er klar til at spille.
Det primære 'kød' i vores klient-server-logik findes i de filer, som er blevet importeret af filen index.js. Nu vil vi gennemgå dem alle én efter én.
4. Dataudveksling mellem klient og server
I dette spil bruger vi et velkendt bibliotek til at kommunikere med serveren. . I Socket.io har indbygget support , som er godt egnet til tovejskommunikation: vi kan sende beskeder til serveren og serveren kan sende beskeder tilbage til os over den samme forbindelse.
Vi får en fil src/client/networking.js, der håndterer alle kommunikationer med serveren:
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('Forbundet til server!');
resolve();
});
});
export const connect = onGameOver => (
connectedPromise.then(() => {
// Registrer 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 kode er også forkortet for klarhedens skyld.
I denne fil sker der tre hovedhandlinger:
- Vi prøver at forbinde til serveren.
connectedPromisebliver kun opfyldt, når vi har oprettet forbindelse. - Hvis forbindelsen er lykkedes, registrerer vi callback-funktioner (
processGameUpdate()ogonGameOver()) for beskeder, vi kan modtage fra serveren. - Vi eksporterer
play()ogupdateDirection(), så de kan bruges af andre filer.
5. Rendering af klienten
Det er tid til at vise billedet på skærmen!
…men før vi kan gøre det, skal vi hente alle billederne (ressourcerne), der er nødvendige. Lad os skrive en ressourcehåndterer:
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(`Downloadet ${assetName}`);
assets[assetName] = asset;
resolve();
};
asset.src = `/assets/${assetName}`;
});
}
export const downloadAssets = () => downloadPromise;
export const getAsset = assetName => assets[assetName]; At håndtere ressourcer er ikke så svært! Hovedideen er at opbevare et objekt assets, som vil forbinde filnavnets nøgle til objektets værdi Billede. Når ressourcen er blevet indlæst, gemmer vi den i objektet assets for hurtig adgang i fremtiden. Når hver enkelt ressourcemulighed er opfyldt (det vil sige ressourcerne er indlæst), opfylder vi alle downloadPromise Når ressourcerne er hentet, kan vi begynde at visualisere. Som nævnt tidligere bruger vi.
HTML5 Canvas (<canvas>). Vores spil er ret enkelt, så vi behøver kun at tegne det følgende:
- Baggrund
- Spillerens skib
- Andre spillere, der er i spillet
- Projektile
Her er vigtige fragmenter src/client/render.js, som explicit viser de nævnte fire punkter:
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;
// Få canvas grafik konteksten
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');
// Gør canvas fuldskærm
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
function render() {
const { me, others, bullets } = getCurrentState();
if (!me) {
return;
}
// Tegn baggrund
renderBackground(me.x, me.y);
// Tegn alle projektile
bullets.forEach(renderBullet.bind(null, me));
// Tegn alle spillere
renderPlayer(me, me);
others.forEach(renderPlayer.bind(null, me));
}
// ... Hjalpefunktioner her udeladt
let renderInterval = null;
export function startRendering() {
renderInterval = setInterval(render, 1000 / 60);
}
export function stopRendering() {
clearInterval(renderInterval);
}Denne kode er også forkortet for overskuelighed.
render() — hovedfunktionen i denne fil. startRendering() og stopRendering() styrer aktiveringen af renderingscyklussen med en frekvens på 60 FPS.
De specifikke implementeringer af de enkelte hjælpfunktioner til rendering (for eksempel renderBullet()) er ikke så vigtige, men her er et simpelt 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,
);
} Bemærk, at vi bruger metoden getAsset(), som vi så tidligere i asset.js!
Hvis du er interesseret i at lære om flere hjælpfunktioner til rendering, så læs resten af .
6. Klientinput
Det er tid til at gøre spillet spillbart! Kontrolskemaet vil være meget simpelt: til at ændre bevægelsesretningen kan du bruge musen (på computer) eller berøring af skærmen (på mobile enheder). For at implementere dette vil vi registrere for Mouse- og Touch-begivenheder.
Det hele vil blive håndteret af 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() og onTouchInput() — disse Event Listeners, der kalder updateDirection() (fra networking.js) ved en input-begivenhed (for eksempel ved at flytte musen). updateDirection() kommunikerer med serveren, der håndterer inputbegivenheder og opdaterer spillets tilstand i overensstemmelse hermed.
7. Klientens tilstand
Denne sektion er den mest komplekse i første del af indlægget. Bliv ikke frustreret, hvis du ikke forstår det første gang! Du kan endda springe over og komme tilbage til det senere.
Den sidste brik i puslespillet, der er nødvendig for at afslutte klient-server-koden, er state. Husk kodefragmentet fra afsnittet "Klient rendering"?
render.js
import { getCurrentState } from './state';
function render() {
const { me, others, bullets } = getCurrentState();
// Udfør rendering
// ...
} getCurrentState() skal være i stand til at give os den nuværende spiltilstand på klienten på ethvert tidspunkt baseret på opdateringer modtaget fra serveren. Her er et eksempel på en spilopdatering, 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 spilopdatering indeholder fem ens felter:
- t: tidsstempel fra serveren, der angiver hvornår denne opdatering blev oprettet.
- me: information om spilleren, der modtager denne opdatering.
- others: et array med information om andre spillere, der deltager i det samme spil.
- bullets: et array med information om projektiler i spillet.
- leaderboard: de aktuelle data i leaderboardet. I dette indlæg vil vi ikke tage dem i betragtning.
7.1 Naiv klienttilstand
Naiv implementering getCurrentState() kan kun direkte returnere dataene fra den senest modtagne spilopdatering.
naive-state.js
let lastGameUpdate = null;
// Håndter en ny modtaget spilopdatering.
export function processGameUpdate(update) {
lastGameUpdate = update;
}
export function getCurrentState() {
return lastGameUpdate;
}Flot og klart! Men hvis kun alt var så simpelt. En af grundene til, at en sådan implementering er problematisk: den begrænser renderingens billedhastighed til serverens taktfrekvens.
Billedhastighed (Frame Rate): antallet af billeder (dvs. kald
render()) pr. sekund, eller FPS. I spil stræber man generelt efter at opnå mindst 60 FPS.
Taktfrekvens (Tick Rate): frekvensen hvormed serveren sender spilopdateringer til klienterne. Ofte er den lavere end billedhastigheden. I vores spil fungerer serveren ved 30 takts pr. sekund.
Hvis vi bare render det seneste spilopdatering, vil FPS i bund og grund aldrig kunne overstige 30, fordi vi aldrig modtager mere end 30 opdateringer fra serveren per sekund. Selvom vi kalder render() 60 gange i sekundet, vil halvdelen af disse opkald blot rendere det samme, hvilket i princippet ikke gør noget. Et andet problem med naive implementeringer er, at de er udsat for forsinkelser. Ved perfekt internetforbindelse vil klienten modtage spilopdateringen præcis hver 33 ms (30 i sekundet):

Desværre er intet perfekt. Et mere realistisk billede ville være:

Den naive implementering er praktisk talt det værst tænkelige tilfælde, når det kommer til forsinkelser. Hvis spilopdateringen modtages med en forsinkelse på 50 ms, så bliver klienten forsinket med ekstra 50 ms, fordi den stadig renderer spiltilstanden fra den tidligere opdatering. Du kan forestille dig, hvor ubehageligt det er for spilleren: På grund af tilfældige forsinkelser vil spillet fremstå hakkende og ustabilt.
7.2 Forbedret klienttilstand
Vi vil foretage nogle forbedringer i den naive implementering. For det første bruger vi renderingsforsinkelse på 100 ms. Det betyder, at den 'nuværende' tilstand af klienten altid vil være 100 ms bag serverens spiltilstand. For eksempel, hvis tiden på serveren er 150, så vil klienten rendere tilstanden, som serveren var i under 50:

Dette giver os et buffer på 100 ms, som gør os i stand til at håndtere den uforudsigelige tid for at modtage spilopdateringer:

Prisen for dette vil være en konstant på 100 ms. Dette er en ubetydelig ofring for en glidende spilleoplevelse — de fleste spillere (især casual spillere) vil endda ikke lægge mærke til denne forsinkelse. Det er meget lettere for folk at tilpasse sig en konstant forsinkelse på 100 ms end at spille med uforudsigelig forsinkelse.
Vi kan også bruge en anden teknik kaldet , som fungerer godt til at reducere oplevede forsinkelser, men vil ikke blive behandlet i dette indlæg.
En anden forbedring, vi bruger, er lineær interpolation. På grund af renderingsforsinkelsen overhaler vi normalt den aktuelle tid på klienten med mindst en opdatering. Når der kaldes getCurrentState(), kan vi udføre mellem opdateringerne af spillet lige før og efter det nuværende tidspunkt i klienten:

Dette løser problemet med billedfrekvens: nu kan vi gengive unikke billeder med enhver ønsket frekvens!
7.3 Implementering af forbedret klienttilstand
Eksempel på implementering i src/client/state.js bruger både gengivelsesforsinkelse og lineær interpolation, men det er kun midlertidigt. Lad os dele koden op i to dele. 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);
// Behold kun én spilopdatering før det aktuelle servertidspunkt
const base = getBaseUpdate();
if (base > 0) {
gameUpdates.splice(0, base);
}
}
function currentServerTime() {
return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY;
}
// Returnerer indekset for basisopdateringen, den første spilopdatering før
// aktuelt servertidspunkt, eller -1 hvis 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 skal vi forstå, hvad currentServerTime()gør. Som vi så før, inkluderer hver spilopdatering en server-tidsstempel. Vi ønsker at bruge gengivelsesforsinkelsen til at gengive billedet med en forsinkelse fra serveren på 100 ms, men vi vil aldrig finde ud af, hvad den aktuelle tid på serveren er, fordi vi ikke kan vide, hvor længe det har taget for nogen af opdateringerne at nå os. Internettet er uforudsigeligt, og hastigheden kan variere meget!
For at omgå dette problem kan vi bruge en fornuftig tilnærmelse: vi låner os til at sige, at den første opdatering ankom øjeblikkeligt. Hvis det var sandt, ville vi vide serverens tid på dette specifikke tidspunkt! Vi gemmer serverens tidsstempel i firstServerTimestamp og gemmer vores lokale (klient) tidsstempel i det samme øjeblik i gameStart.
Åh, vent lidt. Skal ikke serverens tid = klientens tid? Hvorfor skelner vi mellem "server tidsstempel" og "klient tidsstempel"? Det er et godt spørgsmål! Det viser sig, at det ikke er det samme. Date.now() vil returnere forskellige tidsstempler i klienten og serveren, og det afhænger af lokale faktorer for disse maskiner. Antag aldrig, at tidsstemplerne er de samme på alle maskiner.
Nu forstår vi, hvad currentServerTime()gør: det returnerer serverens tidsstempel for den aktuelle gengivelsestid. Med andre ord, det er serverens aktuelle tid (firstServerTimestamp <+(Date.now() - gameStart)) minus renderingsforsinkelsen (RENDER_DELAY).
. Lad os nu se på, hvordan vi behandler spilopdateringer. Når vi modtager en opdatering fra serveren, kaldes processGameUpdate(), og vi gemmer den nye opdatering i arrayet gameUpdates. Derefter, for at kontrollere hukommelsesforbruget, fjerner vi alle gamle opdateringer til den grundlæggende opdatering, da vi ikke længere har brug for dem.
Hvad er en «grundlæggende opdatering»? Det er den første opdatering, vi finder, når vi bevæger os baglæns fra serverens aktuelle tid. Kan du huske denne skema?

Spilopdateringen lige til venstre for «Client Render Time» er den grundlæggende opdatering.
Hvad bruges den grundlæggende opdatering til? Hvorfor kan vi kassere opdateringer til den grundlæggende? For at forstå dette, lad os endelig 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 tilfælde:
base < 0betyder, at der ikke er nogen opdateringer før den aktuelle renderingtid (se ovenstående implementeringgetBaseUpdate()). Dette kan ske i starten af spillet på grund af renderingsforsinkelse. I så fald bruger vi den seneste modtagne opdatering.base— det er den seneste opdatering, vi har. Dette kan ske på grund af netværksforsinkelser eller dårlig internetforbindelse. I dette tilfælde bruger vi også den seneste opdatering, vi har.- Vi har en opdatering både før og efter den aktuelle renderingtid, så vi kan interpolere!
Alt, hvad der er tilbage i state.js — er implementeringen af lineær interpolation, som består af enkel (men kedelig) matematik. Hvis du ønsker at studere det selv, så åbn state.js og få en fungerende kommando. .
Del 2. Backend-server
I denne del vil vi se på backend Node.js, der styrer vores .
1. Indgangspunktet for serveren
For at styre webserveren vil vi bruge det populære webframework for Node.js kaldet . Dets opsætning vil blive håndteret af vores indgangspunkt serverfil 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');
// Opsæt en Express-server
const app = express();
app.use(express.static('public'));
if (process.env.NODE_ENV === 'development') {
// Opsæt Webpack til udvikling
const compiler = webpack(webpackConfig);
app.use(webpackDevMiddleware(compiler));
} else {
// Statisk servere dist/-mappen i produktion
app.use(express.static('dist'));
}
// Lyt på port
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Server lytter på port ${port}`);Husk, at i første del diskuterede vi Webpack? Det er her, vi vil bruge vores Webpack-konfigurationer. Vi vil anvende dem på to måder:
- Brug til automatisk genopbygning af vores udviklingspakker, eller
- Statiske overførsel af mappen
dist/, hvor Webpack vil skrive vores filer efter produktsammensætningen.
En anden vigtig opgave server.js består i at konfigurere serveren , som blot opretter forbindelse til Express-serveren:
server.js, del 2
const socketio = require('socket.io');
const Constants = require('../shared/constants');
// Opsæt Express
// ...
const server = app.listen(port);
console.log(`Server lytter på port ${port}`);
// Opsæt socket.io
const io = socketio(server);
// Lyt efter socket.io-forbindelser
io.on('connection', socket => {
console.log('Spiller tilsluttet!', socket.id);
socket.on(Constants.MSG_TYPES.JOIN_GAME, joinGame);
socket.on(Constants.MSG_TYPES.INPUT, handleInput);
socket.on('disconnect', onDisconnect);
}); Efter en vellykket tilslutning af socket.io til serveren opsætter vi begivenhedsbehandlere for den nye socket. Begivenhedsbehandlerne behandler beskeder modtaget fra klienterne ved at delegere til singleton-objektet game:
server.js, del 3
const Game = require('./game');
// ...
// Opsæt spillet
const game = new Game();
function joinGame(username) {
game.addPlayer(this, username);
}
function handleInput(dir) {
game.handleInput(this, dir);
}
function onDisconnect() {
game.removePlayer(this);
} Vi laver et .io-spil, så vi har kun brug for én instans Game («Game») – alle spillere spiller på samme arena! I næste afsnit vil vi se, hvordan denne klasse fungerer Game.
2. Game-serveren
Klasse Game indeholder den vigtigste logik på serversiden. Den har to hovedopgaver: håndtering af spillere og spilsimulation.
Lad os begynde med den første opgave – håndtering af spillere.
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;
// Generer en position til at starte denne spiller.
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 spil vil vi identificere spillere ved hjælp af feltet id deres socket.io (hvis du er forvirret, så gå tilbage til server.js). Socket.io tildeler automatisk hver socket en unik id, så vi behøver ikke bekymre os om dette. Jeg vil referere til det som Spiller-ID.
Når vi har husket dette, lad os se på instansvariablerne i klassen Game:
sockets— dette er et objekt, der binder spiller-ID til den socket, der er knyttet til spilleren. Det gør det muligt for os at få adgang til sockets baseret på deres spiller-ID'er på konstant tid.players— dette er et objekt, der binder spiller-ID til objektet code>Player
bullets — dette er et array af objekter Bullet, som ikke har nogen bestemt rækkefølge.
lastUpdateTime — dette er et tidsstempel for den sidste opdatering af spillet. Snart vil vi se, hvordan det bruges.
shouldSendUpdate — dette er en hjælpevariabel. Dens brug vil vi også se snart.
Metoder addPlayer(), removePlayer() og handleInput() behoves ikke at blive forklaret, de bruges i server.js. Hvis du har brug for at friske din hukommelse, så gå lidt op.
Den sidste linje constructor() starter spilopdateringscyklussen med en hastighed på 60 opdateringer/s:
game.js, del 2
const Constants = require('..\/shared\/constants');
const applyCollisions = require('.\/collisions');
class Game {
\/\/ ...
update() {
\/\/ Beregn den tid, der er gået
const now = Date.now();
const dt = (now - this.lastUpdateTime) \/ 1000;
this.lastUpdateTime = now;
\/\/ Opdater hver projektil
const bulletsToRemove = [];
this.bullets.forEach(bullet => {
if (bullet.update(dt)) {
\/\/ Ødelæg denne projektil
bulletsToRemove.push(bullet);
}
});
this.bullets = this.bullets.filter(
bullet => !bulletsToRemove.includes(bullet),
);
\/\/ Opdater hver spiller
Object.keys(this.sockets).forEach(playerID => {
const player = this.players[playerID];
const newBullet = player.update(dt);
if (newBullet) {
this.bullets.push(newBullet);
}
});
\/\/ Anvend kollisioner, giv spillerne point for at ramme 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),
);
\/\/ Tjek om der er død
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 en gameplay-opdatering til hver spiller hver anden gang
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;
}
}
\/\/ ...
} Metoden update() indeholder nok den vigtigste del af logikken på serversiden. Lad os gennemgå, hvad den gør:
- Beregn, hvor lang tid
dtder er gået siden sidstupdate(). - Opdaterer hvert projektiv og ødelægger dem om nødvendigt. Vi vil se implementeringen af denne funktionalitet senere. Indtil videre er det nok at vide, at
bullet.update()returnerertrue, hvis projektilen skal ødelægges (den er gået ud over arenaen). - Opdaterer hver spiller og opretter et projektiv hvis nødvendigt. Vi vil også se denne implementering senere —
player.update()kan returnere et objektBullet. - Tjekker kollisioner mellem projektiver og spillere ved hjælp af
applyCollisions(), som returnerer et array af projektiver, der ramte spillerne. For hver returneret projektil giver vi spilleren, der affyrede det, point (ved hjælp afplayer.onDealtDamage()), og derefter fjerner vi projektilen fra arrayetbullets. - Underretter og fjerner alle dræbte spillere.
- Sender en opdatering af spillet til alle spillere hver anden gang ved kaldet
update(). Dette hjælper os med at spore den ovenfor nævnte hjælpevariabelshouldSendUpdate. Sådanupdate()det kaldt 60 gange/s, vi sender opdateringer til spillet 30 gange/s. Sådan er det, klokfrekvens serveren er 30 takts/s (vi talte om klokfrekvens i den første del).
Hvorfor sende opdateringer til spillet kun hver anden gang? ? For at spare båndbredden. 30 opdateringer til spillet i sekundet – det er rigtig meget!
Hvorfor så ikke bare kalde
update()30 gange i sekundet? For at forbedre simuleringen af spillet. Jo oftere det kaldesupdate(), jo mere præcis vil simuleringen være. Men man skal ikke overdrive antallet af opkald,update()for det er en beregningsmæssigt krævende opgave – 60 i sekundet er helt tilstrækkeligt.
Den resterende del af klassen Game består af hjælpe metoder, der bruges 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() er ganske enkel – den sorterer spillerne efter score, tager de fem bedste og returnerer brugernavn og score for hver.
createUpdate() bruges i update() til at skabe opdateringer af spillet, som sendes til spillerne. Dens hovedopgave er at kalde metoderne serializeForUpdate(), implementeret for klasserne Player og Bullet. Vær opmærksom på, at den kun sender data om nærliggende spillere og projektiler – der er ikke behov for at sende oplysninger om spilobjekter, der er langt fra spilleren!
3. Spilobjekter på serveren
I vores spil er projektiler og spillere faktisk meget ens: de er abstrakte, runde, bevægelige spilobjekter. For at udnytte denne lighed mellem spillere og projektiler, lad os starte med at implementere en grundlæggende klasse 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,
};
}
} Her sker der ikke noget kompliceret. Denne klasse vil være et godt udgangspunkt for udvidelser. Lad os se, hvordan klassen Bullet bruger 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 er meget kort! Vi har tilføjet Object kun følgende udvidelser:
- Brug af pakken til tilfældig generation af
idprojektiler. - Tilføjelse af feltet
parentID, så vi kan spore spilleren, der skabte dette projektil. - Tilføjelse af returværdien til
update(), som ertrue, hvis projektilen er uden for arenaen (husker du, vi talte om dette i det tidligere afsnit?).
Lad os gå videre til 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,
};
}
} Spillere er mere komplekse end projektiler, så denne klasse skal gemme et par ekstra felter. Dens metode update() gør et større stykke arbejde, især ved at returnere det nyoprettede projektil, hvis der ikke er tilbage fireCooldown (husker du, vi talte om dette i det tidligere afsnit?). Den udvider også metoden serializeForUpdate(), fordi vi skal inkludere yderligere felter til spilleren i opdateringen af spillet.
At have en grundlæggende klasse Object er et vigtigt skridt for at undgå kode gentagelse. For eksempel uden klassen Object skulle hver spilelement have en identisk implementering af distanceTo(), og synkronisering af kopi-paste af alle disse implementeringer i flere filer ville være et mareridt. Dette bliver særligt vigtigt for større projekter, når antallet af udvidende Object klasser stiger.
4. Kollision genkendelse
Det eneste, vi har tilbage til at gøre, er at genkende, når projektiler rammer spillerne! Husk denne kodefragment fra metoden update() i klassen Game:
game.js
const applyCollisions = require('./collisions');
class Game {
// ...
update() {
// ...
// Anvend kollisioner, giv spillerne point for at ramme 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),
);
// ...
}
} Vi skal implementere metoden applyCollisions(), der returnerer alle projektiler, der rammer spillerne. Heldigvis er det ikke så svært at gøre, fordi
- Alle kolliderende objekter er cirkler, hvilket gør genkendelse af kollisioner til en enkel figur at implementere.
- Vi har allerede metoden
distanceTo(), som vi implementerede i den forrige sektion i klassenObject.
Her er, hvordan vores implementering af kollision genkendelse ser ud:
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;
}Denne enkle kollision genkendelse er baseret på det faktum, at to cirkler kolliderer, hvis afstanden mellem deres centre er mindre end summen af deres radier.Her er et tilfælde, hvor afstanden mellem centrene af to cirkler præcist er lig med summen af deres radier:

Her skal der tages hensyn til et par aspekter:
- Et projektil må ikke ramme den spiller, der skabte det. Dette kan opnås ved at sammenligne
bullet.parentIDmedplayer.id. - Et projektil skal kun ramme én gang i tilfælde af simultan kollision med flere spillere. Dette problem løser vi med operatoren
break: så snart vi finder en spiller, der kolliderer med projektilen, stopper vi søgningen og går videre til det næste projektil.
Slut
Det var det! Vi har dækket alt, hvad du behøver at vide for at skabe et .io-webspil. Hvad er næste skridt? Byg dit eget .io-spil!
Al koden fra eksemplet har åbne kilder og er tilgængelig på .
Kilde: habr.com
