Jokalari anitzeko .io Web Joko bat sortzea

Jokalari anitzeko .io Web Joko bat sortzea
2015ean kaleratua Agar.io genero berri baten aitzindari bihurtu zen jokoak .ioharrezkero ospea hazi dena. Pertsonalki .io jokoen ospearen gorakada bizi izan dut: azken hiru urteetan, hala izan dut genero honetako bi joko sortu eta saldu zituen..

Jolas hauen berri inoiz entzun ez baduzu, doako jokalari anitzeko web-jokoak dira, jolasteko errazak (ez da konturik behar). Jokalari aurkari askori aurre egin ohi diote eremu berean. Beste .io joko ospetsu batzuk: Slither.io ΠΈ Diep.io.

Post honetan, nola aztertuko dugu sortu .io joko bat hutsetik. Horretarako, Javascript jakitea bakarrik nahikoa izango da: sintaxia bezalako gauzak ulertu behar dituzu ES6, gako-hitza this ΠΈ Promesas. Nahiz eta Javascript-en ezagutza perfektua ez izan, mezuaren zatirik handiena uler dezakezu.

.io jokoaren adibidea

Ikasteko laguntzarako, aipatuko dugu .io jokoaren adibidea. Saiatu jolasten!

Jokalari anitzeko .io Web Joko bat sortzea
Jokoa nahiko sinplea da: ontzi bat kontrolatzen duzu beste jokalari batzuk dauden eremu batean. Zure ontziak automatikoki jaurtitzen ditu jaurtigaiak eta beste jokalari batzuk jotzen saiatzen zara, haien jaurtigaiak saihestuz.

1. Proiektuaren ikuspegi laburra / egitura

Gomendatzen dut deskargatu iturburu kodea Adibide-jokoa jarraitu ahal izateko.

Adibideak honako hau erabiltzen du:

  • Express jokoaren web zerbitzaria kudeatzen duen Node.js web esparru ezagunena da.
  • socket.io - websocket liburutegia arakatzaile baten eta zerbitzari baten artean datuak trukatzeko.
  • webpack - moduluen kudeatzailea. Webpack zergatik erabili behar den irakur dezakezu. Hemen.

Hona hemen proiektuaren direktorio-egituraren itxura:

public/
    assets/
        ...
src/
    client/
        css/
            ...
        html/
            index.html
        index.js
        ...
    server/
        server.js
        ...
    shared/
        constants.js

publiko/

Karpeta batean dena public/ zerbitzariak estatikoki bidaliko du. IN public/assets/ gure proiektuak erabilitako irudiak ditu.

src /

Iturburu-kode guztia karpetan dago src/. Izenburuak client/ ΠΈ server/ beren kabuz hitz egin eta shared/ bezeroak eta zerbitzariak inportatutako konstante fitxategi bat dauka.

2. Muntaiak/proiektuaren ezarpenak

Goian esan bezala, modulu-kudeatzailea erabiltzen dugu proiektua eraikitzeko. webpack. Ikus dezagun gure Webpack konfigurazioa:

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',
    }),
  ],
};

Hona hemen ildo garrantzitsuenak:

  • src/client/index.js Javascript (JS) bezeroaren sarrera-puntua da. Webpack hemendik abiatuko da eta inportatutako beste fitxategiak modu errekurtsiboan bilatuko ditu.
  • Gure Webpack eraikuntzaren irteera JS direktorioan kokatuko da dist/. Fitxategi honi gure izena emango diot js paketea.
  • Erabiltzen dugu Babel, eta bereziki konfigurazioa @babel/preset-env nabigatzaile zaharrentzako gure JS kodea transpilatzeko.
  • Plugin bat erabiltzen ari gara JS fitxategiek erreferentziatutako CSS guztiak ateratzeko eta leku bakarrean konbinatzeko. Gure deituko diot css paketea.

Baliteke paketeen fitxategi-izen arraroak nabaritzea '[name].[contenthash].ext'. Edukitzen dute fitxategi-izenen ordezkapenak webpack: [name] sarrera puntuaren izenarekin ordezkatuko da (gure kasuan, hau game), eta [contenthash] fitxategiaren edukiaren hash batekin ordezkatuko da. Guk egiten dugu optimizatu proiektua hashing egiteko - Arakatzaileei esan diezaiekezu gure JS paketeak cachean gordetzeko mugarik gabe, zeren pakete bat aldatzen bada, bere fitxategi-izena ere aldatzen da (aldaketak contenthash). Azken emaitza ikusteko fitxategiaren izena izango da game.dbeee76e91a97d0c7207.js.

fitxategia webpack.common.js garapenerako eta amaitutako proiektuen konfigurazioetara inportatzen dugun oinarrizko konfigurazio fitxategia da. Hona hemen garapenaren konfigurazio adibide bat:

webpack.dev.js

const merge = require('webpack-merge');
const common = require('./webpack.common.js');

module.exports = merge(common, {
  mode: 'development',
});

Eraginkortasuna lortzeko, garapen prozesuan erabiltzen dugu webpack.dev.js, eta aldatzen da webpack.prod.jspaketeen tamainak optimizatzeko ekoizpenera zabaltzean.

Tokiko ezarpena

Proiektua tokiko makina batean instalatzea gomendatzen dut, argitalpen honetan agertzen diren urratsak jarraitu ahal izateko. Konfigurazioa erraza da: lehenik, sistema instalatuta egon behar da Nodoa ΠΈ NPM. Hurrengoa egin behar duzu

$ git clone https://github.com/vzhou842/example-.io-game.git
$ cd example-.io-game
$ npm install

eta prest zaude joateko! Garapen zerbitzaria abiarazteko, exekutatu besterik ez duzu

$ npm run develop

eta joan web arakatzailera localhost: 3000. Garapen zerbitzariak JS eta CSS paketeak automatikoki berreraikiko ditu kodea aldatzen den heinean - freskatu orrialdea aldaketa guztiak ikusteko!

3. Bezeroen Sarrera Puntuak

Goazen jokoaren kodea bera. Lehenik orri bat behar dugu index.html, gunea bisitatzean, nabigatzaileak lehenik kargatuko du. Gure orria nahiko sinplea izango da:

index.html

Adibide bat .io joko bat  JOLASTU

Kode-adibide hau apur bat sinplifikatu da argitasunerako, eta gauza bera egingo dut beste post-adibide askorekin. Kode osoa beti ikus daiteke hemen Github.

Daukagu:

  • HTML5 mihise elementua (<canvas>) jokoa errendatzeko erabiliko duguna.
  • <link> gure CSS paketea gehitzeko.
  • <script> gure Javascript paketea gehitzeko.
  • Menu nagusia erabiltzaile izenarekin <input> eta PLAY botoia (<button>).

Hasierako orria kargatu ondoren, arakatzailea Javascript kodea exekutatzen hasiko da, sarrera puntuko JS fitxategitik hasita: src/client/index.js.

aurkibidea.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);
  };
});

Konplikatua dirudi, baina hemen ez da gauza handirik gertatzen:

  1. Beste hainbat JS fitxategi inportatzen.
  2. CSS inportazioa (beraz, Webpack-ek badaki gure CSS paketean sartzen dituela).
  3. aireratzea connect() zerbitzariarekin konexioa ezartzeko eta exekutatzeko downloadAssets() jokoa errendatzeko behar diren irudiak deskargatzeko.
  4. 3. etapa amaitu ondoren menu nagusia bistaratzen da (playMenu).
  5. "PLAY" botoia sakatzeko kudeatzailea ezartzea. Botoia sakatzean, kodeak jokoa hasieratzen du eta zerbitzariari jokatzeko prest gaudela esaten dio.

Gure bezero-zerbitzari logikaren "haragia" nagusia fitxategiak inportatu zituen fitxategi horietan dago index.js. Orain ordenan aztertuko ditugu guztiak.

4. Bezeroen datuen trukea

Joko honetan, liburutegi ezagun bat erabiltzen dugu zerbitzariarekin komunikatzeko socket.io. Socket.io-k jatorrizko laguntza du WebSocket-ak, bi norabideko komunikaziorako ondo egokitzen direnak: zerbitzariari mezuak bidal ditzakegu ΠΈ zerbitzariak mezuak bidal diezazkiguke konexio berean.

Fitxategi bat izango dugu src/client/networking.jsnork zainduko duen denek zerbitzariarekin komunikazioa:

sareak.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);
};

Kode hau ere zertxobait laburtu da argitasunerako.

Fitxategi honetan hiru ekintza nagusi daude:

  • Zerbitzariarekin konektatzen saiatzen ari gara. connectedPromise konexio bat ezarri dugunean soilik onartzen da.
  • Konexioa arrakastatsua bada, deia itzultzeko funtzioak erregistratuko ditugu (processGameUpdate() ΠΈ onGameOver()) zerbitzaritik jaso ditzakegun mezuetarako.
  • Esportatzen dugu play() ΠΈ updateDirection()beste fitxategi batzuk erabil ditzaten.

5. Bezeroen errendatzea

Irudia pantailan bistaratzeko garaia da!

…baina hori egin aurretik, horretarako behar diren irudi (baliabide) guztiak deskargatu behar ditugu. Idatz dezagun baliabideen kudeatzailea:

aktiboak.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];

Baliabideen kudeaketa ez da hain zaila ezartzea! Ideia nagusia objektu bat gordetzea da assets, fitxategi-izenaren gakoa objektuaren balioarekin lotuko duena Image. Baliabidea kargatzen denean, objektu batean gordetzen dugu assets etorkizunean sarbide azkarra izateko. Noiz baimenduko da baliabide bakoitza deskargatzeko (hau da, guztiak baliabideak), onartzen dugu downloadPromise.

Baliabideak deskargatu ondoren, errendatzen has zaitezke. Lehen esan bezala, web orri batean marrazteko, erabiltzen dugu HTML5 mihisea (<canvas>). Gure jokoa nahiko erraza da, beraz, honako hauek marraztu besterik ez dugu egin behar:

  1. hondo
  2. Jokalari ontzia
  3. Jokoan dauden beste jokalari batzuk
  4. Maskorrak

Hona hemen zati garrantzitsuak src/client/render.js, goian zerrendatutako lau elementuak zehatz-mehatz ematen dituztenak:

errendatu.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);
}

Kode hau laburtu egiten da argitasunerako.

render() fitxategi honen funtzio nagusia da. startRendering() ΠΈ stopRendering() kontrolatu errendatze-begizta aktibatzea 60 FPS-tan.

Banakako errendatze-funtzio laguntzaileen inplementazio zehatzak (adibidez. renderBullet()) ez dira horren garrantzitsuak, baina hona hemen adibide sinple bat:

errendatu.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,
  );
}

Kontuan izan metodoa erabiltzen ari garela getAsset(), lehenago urtean ikusi zena asset.js!

Errendatzeko beste laguntzaile batzuk ezagutzeko interesa baduzu, irakurri gainerakoa. src/client/render.js.

6. Bezeroaren sarrera

Jolas bat egiteko garaia da erreproduzigarria! Kontrol-eskema oso erraza izango da: mugimenduaren norabidea aldatzeko, sagua erabil dezakezu (ordenagailu batean) edo pantaila ukitu (gailu mugikor batean). Hau gauzatzeko, izena emango dugu Ekitaldi Entzuleak Mouse eta Touch ekitaldietarako.
Hori guztiaz arduratuko da src/client/input.js:

sarrera.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() deitzen duten Gertaeren Entzuleak dira updateDirection() (of networking.js) sarrerako gertaera bat gertatzen denean (adibidez, sagua mugitzen denean). updateDirection() zerbitzariarekin mezularitza kudeatzen du, eta horrek sarrerako gertaera kudeatzen du eta horren arabera jokoaren egoera eguneratzen du.

7. Bezeroaren egoera

Atal hau postaren lehen zatian zailena da. Ez zaitez desanimatu irakurtzen duzun lehen aldian ulertzen ez baduzu! Saltatu eta beranduago itzuli ere egin dezakezu.

Bezero/zerbitzariaren kodea osatzeko behar den puzzlearen azken pieza da egoera. Gogoratzen al duzu Bezeroaren Errendaketa ataleko kode zatia?

errendatu.js

import { getCurrentState } from './state';

function render() {
  const { me, others, bullets } = getCurrentState();

  // Do the rendering
  // ...
}

getCurrentState() Bezeroan jokoaren egungo egoera emateko gai izan beharko luke edozein momentutan zerbitzaritik jasotako eguneraketetan oinarrituta. Hona hemen zerbitzariak bidal dezakeen joko eguneratzearen adibide bat:

{
  "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
    }
  ]
}

Jokoaren eguneratze bakoitzak bost eremu berdin ditu:

  • t: Eguneratze hau noiz sortu den adierazten duen zerbitzariaren ordu-zigilua.
  • me: eguneraketa hau jasotzen duen jokalariari buruzko informazioa.
  • beste batzuk: Joko berean parte hartzen duten beste jokalariei buruzko informazio sorta bat.
  • balak: jokoko proiektilei buruzko informazio sorta bat.
  • leaderboard: Uneko sailkapeneko datuak. Post honetan, ez ditugu kontuan hartuko.

7.1 Bezeroaren egoera inozoa

Ezarpen inozoa getCurrentState() Azken jokoaren eguneratzearen datuak zuzenean itzuli ditzake.

naive-state.js

let lastGameUpdate = null;

// Handle a newly received game update.
export function processGameUpdate(update) {
  lastGameUpdate = update;
}

export function getCurrentState() {
  return lastGameUpdate;
}

Polita eta argia! Baina hain sinplea balitz. Inplementazio honen arrazoietako bat problematikoa da: errendatzeko fotograma-abiadura zerbitzariaren erloju-abiadurara mugatzen du.

Fotograma-tasa: fotograma kopurua (hau da, deiak render()) segundoko edo FPS. Jolasak gutxienez 60 FPS lortzen saiatzen dira.

Tick-tasa: Zerbitzariak jokoaren eguneraketak bezeroei bidaltzen dituen maiztasuna. Askotan fotograma-tasa baino txikiagoa da. Gure jokoan, zerbitzaria segundoko 30 zikloko maiztasunarekin exekutatzen da.

Jokoaren azken eguneratzea erreproduzitzen badugu, FPSak funtsean ez du inoiz 30etik gora igaroko, zeren ez dugu inoiz 30 eguneratze baino gehiago jasotzen segundoko zerbitzaritik. Deitzen badugu ere render() 60 aldiz segundoko, orduan dei horien erdiak gauza bera berriro marraztuko du, funtsean ezer egin gabe. Ezarpen inozoaren beste arazo bat hori da atzerapenak izateko joera. Interneteko abiadura ezin hobea izanik, bezeroak jokoaren eguneraketa bat jasoko du zehazki 33 ms behin (30 segundoko):

Jokalari anitzeko .io Web Joko bat sortzea
Zoritxarrez, ezer ez da perfektua. Irudi errealistagoa izango litzateke:
Jokalari anitzeko .io Web Joko bat sortzea
Inplementazio inozoa da ia kasurik txarrena latentziari dagokionez. Jokoaren eguneratze bat 50 ms-ko atzerapenarekin jasotzen bada, orduan bezeroen postuak 50 ms gehiago aurreko eguneratzetik jokoaren egoera errendatzen ari delako. Imajina dezakezu jokalariarentzat zein deserosoa den hori: balazta arbitrarioak jokoa zalaparta eta ezegonkorra sentiaraziko du.

7.2 Bezeroaren egoera hobetua

Inplementazio inozoan hobekuntza batzuk egingo ditugu. Lehenik eta behin, erabiltzen dugu errendatzeko atzerapena 100 ms-rako. Horrek esan nahi du bezeroaren "uneko" egoera beti zerbitzariko jokoaren egoeraren atzean geratuko dela 100 ms. Adibidez, zerbitzarian ordua bada 150, orduan bezeroak zerbitzariak momentuan zegoen egoera errendatuko du 50:

Jokalari anitzeko .io Web Joko bat sortzea
Honek 100 ms-ko buffer bat ematen digu jokoaren eguneratze-aldi ezustekoak bizirauteko:

Jokalari anitzeko .io Web Joko bat sortzea
Honen ordaina iraunkorra izango da sarrerako atzerapena 100 ms-rako. Jokatzeko sakrifizio txikia da hau: jokalari gehienek (batez ere jokalari casualek) ez dute atzerapen hori nabarituko. Jendearentzat askoz errazagoa da 100 ms-ko latentzia konstante batera egokitzea ezusteko latentziarekin jolastea baino.

izeneko beste teknika bat ere erabil dezakegu bezeroaren aldetik aurreikuspena, hautemandako latentzia murrizteko lan ona egiten duena, baina ez da argitalpen honetan landuko.

Erabiltzen ari garen beste hobekuntza bat da interpolazio lineala. Errendatzeko atzerapena dela eta, bezeroaren uneko orduarekiko gutxienez eguneratze bat aurreratu ohi dugu. Deitzen denean getCurrentState(), exekutatu dezakegu interpolazio lineala jokoaren eguneratzeen artean, bezeroaren uneko orduaren aurretik eta ondoren:

Jokalari anitzeko .io Web Joko bat sortzea
Horrek fotograma-abiaduraren arazoa konpontzen du: orain fotograma bereziak errenda ditzakegu nahi dugun fotograma-abiaduran!

7.3 Bezeroaren egoera hobetua ezartzea

Inplementazio adibidea src/client/state.js Errendatzeko atzerapena eta interpolazio lineala erabiltzen ditu, baina ez luzaroan. Hautsi dezagun kodea bi zatitan. Hona hemen lehenengoa:

egoera.js 1. zatia

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;
}

Lehen urratsa zer den jakitea da currentServerTime(). Lehen ikusi dugun bezala, jokoaren eguneratze bakoitzak zerbitzariaren denbora-zigilua dauka. Errendatzeko latentzia erabili nahi dugu irudia zerbitzariaren atzean 100ms errendatzeko, baina ez dugu inoiz zerbitzarian dagoen uneko ordua jakingo, ezin dugulako jakin zenbat denbora behar izan den eguneratzeren bat guregana iristeko. Internet ezustekoa da eta bere abiadura asko alda daiteke!

Arazo honi aurre egiteko, zentzuzko hurbilketa bat erabil dezakegu: gu lehen eguneratzea berehala iritsi dela iruditu. Hau egia balitz, une zehatz honetan zerbitzariaren ordua jakingo genuke! Zerbitzariaren denbora-zigilua gordetzen dugu firstServerTimestamp eta mantendu gure tokikoa (bezeroa) denbora-zigilua une berean gameStart.

Ai itxaron. Ez al luke zerbitzariaren ordua = bezeroaren ordua izan behar? Zergatik bereizten ditugu "zerbitzariaren denbora-zigilua" eta "bezeroaren denbora-zigilua"? Hau galdera bikaina da! Ematen du ez direla gauza bera. Date.now() denbora-zigilu desberdinak itzuliko ditu bezeroan eta zerbitzarian, eta makina horien tokiko faktoreen araberakoa da. Inoiz ez suposatu denbora-zigiluak makina guztietan berdinak izango direla.

Orain ulertzen dugu zer egiten duen currentServerTime(): itzultzen da uneko errendatze-denboraren zerbitzariaren denbora-zigilua. Beste era batera esanda, zerbitzariaren uneko ordua da (firstServerTimestamp <+ (Date.now() - gameStart)) ken errendatzeko atzerapena (RENDER_DELAY).

Ikus dezagun nola kudeatzen ditugun jokoen eguneraketak. Eguneratze zerbitzaritik jasotzen denean, deitzen zaio processGameUpdate()eta eguneratze berria array batean gordetzen dugu gameUpdates. Ondoren, memoriaren erabilera egiaztatzeko, aurreko eguneratze zahar guztiak kentzen ditugu oinarrizko eguneratzeaez ditugulako gehiago behar.

Zer da "oinarrizko eguneraketa" bat? Hau zerbitzariaren uneko ordutik atzera eginez aurkitzen dugun lehen eguneraketa. Gogoratzen al duzu diagrama hau?

Jokalari anitzeko .io Web Joko bat sortzea
"Client Render Time"-ren ezkerrean dagoen jokoaren eguneratzea oinarrizko eguneratzea da.

Zertarako erabiltzen da oinarrizko eguneratzea? Zergatik jar ditzakegu eguneratzeak oinarrizko lerrora? Hau asmatzeko, goazen azkenik inplementazioa kontuan hartu getCurrentState():

egoera.js 2. zatia

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),
    };
  }
}

Hiru kasu kudeatzen ditugu:

  1. base < 0 esan nahi du ez dagoela eguneratzerik uneko errendatze-denbora arte (ikus goiko inplementazioa getBaseUpdate()). Hau jokoaren hasieran bertan gerta daiteke errendatzearen desfasearen ondorioz. Kasu honetan, jasotako azken eguneratzea erabiltzen dugu.
  2. base daukagun azken eguneratzea da. Sareko atzerapenagatik edo Interneteko konexio eskasagatik izan daiteke hori. Kasu honetan, daukagun azken eguneratzea ere erabiltzen ari gara.
  3. Eguneratze bat dugu uneko errendatze-denbora baino lehen eta ondoren, beraz interpolatu!

Barruan geratzen dena state.js Matematika sinple (baina aspergarria) den interpolazio linealaren inplementazioa da. Zuk zeuk arakatu nahi baduzu, ireki state.js on Github.

2. zatia. Backend zerbitzaria

Zati honetan, gure kontrolatzen duen Node.js backend-ari begiratuko diogu .io jokoaren adibidea.

1. Zerbitzariaren Sarrera Puntua

Web zerbitzaria kudeatzeko, Node.js izeneko web esparru ezagun bat erabiliko dugu Express. Gure zerbitzariaren sarrera-puntuaren fitxategiak konfiguratuko du src/server/server.js:

server.js 1. zatia

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}`);

Gogoratzen al duzu lehen zatian Webpack eztabaidatu genuela? Hemen gure Webpack konfigurazioak erabiliko ditugu. Bi modutara erabiliko ditugu:

  • erabiltzea webpack-dev-middleware gure garapen paketeak automatikoki berreraikitzeko, edo
  • estatikoki transferitzeko karpeta dist/, eta bertan Webpack-ek gure fitxategiak idatziko ditu produkzioa eraiki ondoren.

Beste zeregin garrantzitsu bat server.js zerbitzaria konfiguratzea da socket.ioExpress zerbitzariarekin konektatzen dena:

server.js 2. zatia

const socketio = require('socket.io');
const Constants = require('../shared/constants');

// Setup Express
// ...
const server = app.listen(port);
console.log(`Server listening on port ${port}`);

// Setup socket.io
const io = socketio(server);

// Listen for socket.io connections
io.on('connection', socket => {
  console.log('Player connected!', socket.id);

  socket.on(Constants.MSG_TYPES.JOIN_GAME, joinGame);
  socket.on(Constants.MSG_TYPES.INPUT, handleInput);
  socket.on('disconnect', onDisconnect);
});

Socket.io zerbitzarirako konexioa behar bezala ezarri ondoren, socket berrirako gertaeren kudeatzaileak konfiguratu ditugu. Gertaeren kudeatzaileek bezeroengandik jasotako mezuak kudeatzen dituzte singleton objektu bati delegatuz game:

server.js 3. zatia

const Game = require('./game');

// ...

// Setup the Game
const game = new Game();

function joinGame(username) {
  game.addPlayer(this, username);
}

function handleInput(dir) {
  game.handleInput(this, dir);
}

function onDisconnect() {
  game.removePlayer(this);
}

.io joko bat sortzen ari gara, beraz, kopia bakarra behar dugu Game ("Jokoa") - jokalari guztiek eremu berean jokatzen dute! Hurrengo atalean, klase honek nola funtzionatzen duen ikusiko dugu. Game.

2. Joko zerbitzariak

Class Game zerbitzariaren aldean logika garrantzitsuena dauka. Bi zeregin nagusi ditu: jokalarien kudeaketa ΠΈ jokoaren simulazioa.

Has gaitezen lehen zereginarekin, jokalarien kudeaketarekin.

game.js 1. zatia

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);
    }
  }

  // ...
}

Joko honetan, jokalariak eremuaren arabera identifikatuko ditugu id beren socket.io socket (nahasten bazara, itzuli server.js). Socket.io-k berak socket bakoitzari bakarra esleitzen dio idberaz, ez dugu horregatik kezkatu behar. deituko diot Jokalariaren IDa.

Hori kontuan izanda, azter ditzagun klase bateko instantzia-aldagaiak Game:

  • sockets jokalariaren IDa jokalariarekin lotutako socketarekin lotzen duen objektu bat da. Beren jokalari IDen bidez socketetara sartzeko aukera ematen digu denbora etengabean.
  • players Jokalari IDa kodea>Jokalari objektua lotzen duen objektua da

bullets objektu sorta bat da Bullet, ordena zehatzik ez duena.
lastUpdateTime Jokoa eguneratu zen azkeneko denbora-zigilua da. Laster ikusiko dugu nola erabiltzen den.
shouldSendUpdate aldagai laguntzailea da. Bere erabilera ere laster ikusiko dugu.
metodoak addPlayer(), removePlayer() ΠΈ handleInput() ez dago azaldu beharrik, erabiltzen dira server.js. Memoria freskatu behar baduzu, atzera pixka bat gorago.

Azken lerroa constructor() martxan jartzen da eguneratzeko zikloa jokoak (60 eguneratze/s-ko maiztasunarekin):

game.js 2. zatia

const Constants = require('../shared/constants');
const applyCollisions = require('./collisions');

class Game {
  // ...

  update() {
    // Calculate time elapsed
    const now = Date.now();
    const dt = (now - this.lastUpdateTime) / 1000;
    this.lastUpdateTime = now;

    // Update each bullet
    const bulletsToRemove = [];
    this.bullets.forEach(bullet => {
      if (bullet.update(dt)) {
        // Destroy this bullet
        bulletsToRemove.push(bullet);
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !bulletsToRemove.includes(bullet),
    );

    // Update each player
    Object.keys(this.sockets).forEach(playerID => {
      const player = this.players[playerID];
      const newBullet = player.update(dt);
      if (newBullet) {
        this.bullets.push(newBullet);
      }
    });

    // Apply collisions, give players score for hitting bullets
    const destroyedBullets = applyCollisions(
      Object.values(this.players),
      this.bullets,
    );
    destroyedBullets.forEach(b => {
      if (this.players[b.parentID]) {
        this.players[b.parentID].onDealtDamage();
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !destroyedBullets.includes(bullet),
    );

    // Check if any players are dead
    Object.keys(this.sockets).forEach(playerID => {
      const socket = this.sockets[playerID];
      const player = this.players[playerID];
      if (player.hp <= 0) {
        socket.emit(Constants.MSG_TYPES.GAME_OVER);
        this.removePlayer(socket);
      }
    });

    // Send a game update to each player every other time
    if (this.shouldSendUpdate) {
      const leaderboard = this.getLeaderboard();
      Object.keys(this.sockets).forEach(playerID => {
        const socket = this.sockets[playerID];
        const player = this.players[playerID];
        socket.emit(
          Constants.MSG_TYPES.GAME_UPDATE,
          this.createUpdate(player, leaderboard),
        );
      });
      this.shouldSendUpdate = false;
    } else {
      this.shouldSendUpdate = true;
    }
  }

  // ...
}

ΠœΠ΅Ρ‚ΠΎΠ΄ update() du agian zerbitzariaren logikaren zatirik garrantzitsuena. Hona hemen zer egiten duen, ordenan:

  1. Zenbat denbora kalkulatzen du dt azkenetik pasatu zen update().
  2. Proyectil bakoitza freskatzen du eta beharrezkoa izanez gero suntsitzen ditu. Funtzio honen ezarpena aurrerago ikusiko dugu. Oraingoz, nahikoa da hori jakitea bullet.update() itzultzen truejaurtigaia suntsitu behar bada (Arenatik atera zen).
  3. Jokalari bakoitza eguneratzen du eta behar izanez gero, jaurtigai bat sortzen du. Inplementazio hau geroago ikusiko dugu - player.update() objektu bat itzul dezake Bullet.
  4. Proyectilen eta jokalarien arteko talkak egiaztatzen ditu applyCollisions(), jokalariak jotzen dituen proiektil sorta bat itzultzen duena. Itzulitako jaurtigai bakoitzeko, jaurti duen jokalariaren puntuak handitzen ditugu (erabiliz player.onDealtDamage()) eta, ondoren, kendu proiekta arraytik bullets.
  5. Hildako jokalari guztiak jakinarazi eta suntsitzen ditu.
  6. Jokalari guztiei jokoaren eguneraketa bidaltzen die segundoro aldiz deituta update(). Horrek goian aipatutako aldagai laguntzailearen jarraipena egiten laguntzen digu. shouldSendUpdate. As update() 60 aldiz/s deituta, jokoaren eguneraketak 30 aldiz/s bidaltzen ditugu. Horrela, erlojuaren maiztasuna zerbitzariaren erlojua 30 erloju/s-koa da (erlojuaren maizetaz hitz egin genuen lehen zatian).

Zergatik bidali jokoen eguneraketak soilik denboran zehar ? Kanala gordetzeko. 30 jokoen eguneraketa segundoko asko da!

Zergatik ez deitu besterik ez update() 30 aldiz segundoko? Jokoaren simulazioa hobetzeko. Zenbat eta maizago deitu update(), orduan eta zehatzagoa izango da jokoaren simulazioa. Baina ez zaitez gehiegi eraman erronka kopuruarekin. update(), hau konputazionalki lan garestia delako - 60 segundoko nahikoa da.

Gainerako klaseak Game urtean erabiltzen diren metodo laguntzaileek osatzen dute update():

game.js 3. zatia

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() nahiko sinplea: jokalariak puntuazioaren arabera ordenatzen ditu, lehen bostenak hartzen ditu eta bakoitzaren erabiltzaile-izena eta puntuazioa itzultzen ditu.

createUpdate() urtean erabiltzen da update() jokalariei banatzen zaizkien jokoen eguneraketak sortzeko. Bere zeregin nagusia metodoak deitzea da serializeForUpdate()klaseetarako ezarrita Player ΠΈ Bullet. Kontuan izan jokalari bakoitzari buruzko datuak soilik pasatzen dizkiola hurbilena jokalariak eta proiektilak - ez dago jokalariengandik urrun dauden joko-objektuei buruzko informazioa transmititu beharrik!

3. Joko-objektuak zerbitzarian

Gure jokoan, proiektilak eta jokalariak oso antzekoak dira benetan: joko-objektu abstraktuak, biribilak eta higigarriak dira. Jokalari eta jaurtigaien arteko antzekotasun hori aprobetxatzeko, has gaitezen oinarrizko klasea inplementatzen Object:

objektua.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,
    };
  }
}

Hemen ez dago ezer konplikaturik gertatzen. Klase hau luzapenerako aingura-puntu ona izango da. Ea nola klasea Bullet erabilerak 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;
  }
}

Inplementazioa Bullet oso laburra! Gehitu dugu Object luzapen hauek bakarrik:

  • Paketea erabiliz laburra ausazko belaunaldirako id jaurtigaia.
  • Eremu bat gehitzea parentIDhorrela, jaurtigai hau sortu duen jokalariaren jarraipena egin dezakezu.
  • Honi itzulera-balioa gehitzea update(), hau da, berdina truejaurtiketa arenatik kanpo badago (gogoratzen al duzu azken atalean honetaz hitz egin genuela?).

Goazen Player:

jokalari.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,
    };
  }
}

Jokalariak proiektilak baino konplexuagoak dira, beraz, eremu batzuk gehiago gorde behar dira klase honetan. Bere metodoa update() lan asko egiten du, bereziki, sortu berria den jaurtigaia itzultzen du ezer geratzen ez bada fireCooldown (Gogoratzen duzu honetaz hitz egin genuela aurreko atalean?). Metodoa ere zabaltzen du serializeForUpdate(), jokoaren eguneratzean jokalariarentzat eremu osagarriak sartu behar ditugulako.

Oinarrizko klasea izatea Object - urrats garrantzitsua kodea errepika ez dadin. Adibidez, klaserik ez Object joko-objektu guztiek inplementazio bera izan behar dute distanceTo(), eta inplementazio horiek guztiak fitxategi anitzetan kopiatu-itsatsi amesgaizto bat izango litzateke. Hau bereziki garrantzitsua da proiektu handietarako.zabaltzeko kopurua denean Object klaseak hazten ari dira.

4. Talkak hautematea

Guri geratzen zaigun gauza bakarra da errekonozitzea jaurtigaiek jokalariak noiz jotzen dituzten! Gogoratu metodoko kode zati hau update() klasean Game:

jokoa.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),
    );

    // ...
  }
}

Metodoa ezarri behar dugu applyCollisions(), jokalariak jotzen dituzten jaurtigai guztiak itzultzen dituena. Zorionez, ez da hain zaila egiten

  • Talka egiten duten objektu guztiak zirkuluak dira, talkak detektatzeko forma errazena da.
  • Dagoeneko badugu metodo bat distanceTo(), klasean aurreko atalean ezarri genuena Object.

Hona hemen gure talkak hautematearen ezarpena nolakoa den:

talkak.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;
}

Talkak hautemate sinple hau horretan oinarritzen da bi zirkuluk talka egiten dute haien zentroen arteko distantzia erradioen batura baino txikiagoa bada. Hona hemen bi zirkuluren zentroen arteko distantzia beren erradioen baturaren berdina den kasua:

Jokalari anitzeko .io Web Joko bat sortzea
Hemen kontuan hartu beharreko pare bat alderdi gehiago daude:

  • Proyectilak ez du jo behar sortu duen jokalaria. Konparatuz lor daiteke bullet.parentID с player.id.
  • Proyectilak behin bakarrik jo behar du hainbat jokalarik aldi berean talka egiten duten kasu mugatuan. Arazo hau operadorea erabiliz konponduko dugu break: proiektilarekin talka egiten duen jokalaria aurkitu bezain laster, bilaketa gelditu eta hurrengo proiektilara igaroko gara.

end

Hori da dena! .io web-joko bat sortzeko jakin behar duzun guztia azaldu dugu. Zer da hurrengoa? Eraiki zure .io jokoa!

Lagin-kode guztia kode irekia da eta bertan argitaratuta dago Github.

Iturria: www.habr.com

Gehitu iruzkin berria