Δημιουργία ενός παιχνιδιού Web για πολλούς παίκτες .io

Δημιουργία ενός παιχνιδιού Web για πολλούς παίκτες .io
Κυκλοφόρησε το 2015 Agar έγινε ο γενάρχης ενός νέου είδους παιχνίδια .ioπου έχει αυξηθεί σε δημοτικότητα από τότε. Έχω βιώσει προσωπικά την αύξηση της δημοτικότητας των παιχνιδιών .io: τα τελευταία τρία χρόνια, έχω βιώσει δημιούργησε και πούλησε δύο παιχνίδια αυτού του είδους..

Σε περίπτωση που δεν έχετε ξανακούσει για αυτά τα παιχνίδια, αυτά είναι δωρεάν παιχνίδια ιστού για πολλούς παίκτες που παίζονται εύκολα (δεν απαιτείται λογαριασμός). Συνήθως αντιμετωπίζουν πολλούς αντίπαλους παίκτες στην ίδια αρένα. Άλλα διάσημα παιχνίδια .io: Slither.io и Diep.io.

Σε αυτήν την ανάρτηση, θα διερευνήσουμε πώς δημιουργήστε ένα παιχνίδι .io από την αρχή. Για αυτό, μόνο η γνώση Javascript θα είναι αρκετή: πρέπει να κατανοήσετε πράγματα όπως η σύνταξη ES6, λέξη-κλειδί this и Υποσχέσεις. Ακόμα κι αν οι γνώσεις σας στη Javascript δεν είναι τέλειες, μπορείτε να κατανοήσετε το μεγαλύτερο μέρος της ανάρτησης.

Παράδειγμα παιχνιδιού .io

Για μαθησιακή βοήθεια, θα αναφερθούμε στο Παράδειγμα παιχνιδιού .io. Προσπαθήστε να το παίξετε!

Δημιουργία ενός παιχνιδιού Web για πολλούς παίκτες .io
Το παιχνίδι είναι αρκετά απλό: ελέγχετε ένα πλοίο σε μια αρένα όπου υπάρχουν άλλοι παίκτες. Το σκάφος σας εκτοξεύει αυτόματα βλήματα και προσπαθείτε να χτυπήσετε άλλους παίκτες αποφεύγοντας τα βλήματα τους.

1. Σύντομη επισκόπηση / δομή του έργου

Συνιστώ κατεβάστε τον πηγαίο κώδικα παράδειγμα παιχνιδιού για να με ακολουθήσετε.

Το παράδειγμα χρησιμοποιεί τα εξής:

  • Εxpress είναι το πιο δημοφιλές πλαίσιο web Node.js που διαχειρίζεται τον διακομιστή ιστού του παιχνιδιού.
  • socket.io - μια βιβλιοθήκη websocket για την ανταλλαγή δεδομένων μεταξύ ενός προγράμματος περιήγησης και ενός διακομιστή.
  • Webpack - διαχειριστής ενότητας. Μπορείτε να διαβάσετε γιατί να χρησιμοποιήσετε το Webpack. εδώ.

Ακολουθεί η εμφάνιση της δομής του καταλόγου του έργου:

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

δημόσιο/

Όλα σε ένα φάκελο public/ θα υποβάλλονται στατικά από τον διακομιστή. ΣΕ public/assets/ περιέχει εικόνες που χρησιμοποιούνται από το έργο μας.

src /

Όλος ο πηγαίος κώδικας βρίσκεται στο φάκελο src/. Τίτλοι client/ и server/ μιλούν για τον εαυτό τους και shared/ περιέχει ένα αρχείο σταθερών που εισάγεται τόσο από τον πελάτη όσο και από τον διακομιστή.

2. Συναρμολογήσεις/Ρυθμίσεις έργου

Όπως αναφέρθηκε παραπάνω, χρησιμοποιούμε τον διαχειριστή λειτουργιών για την κατασκευή του έργου. Webpack. Ας ρίξουμε μια ματιά στη διαμόρφωση του πακέτου Web:

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

Οι πιο σημαντικές γραμμές εδώ είναι:

  • src/client/index.js είναι το σημείο εισόδου του προγράμματος-πελάτη Javascript (JS). Το Webpack θα ξεκινήσει από εδώ και θα αναζητήσει αναδρομικά για άλλα εισαγόμενα αρχεία.
  • Το JS εξόδου της κατασκευής του Webpack θα βρίσκεται στον κατάλογο dist/. Αυτό το αρχείο θα το ονομάσω δικό μας js πακέτο.
  • Χρησιμοποιούμε Βαβέλ, και ειδικότερα τη διαμόρφωση @babel/preset-env στη μεταγραφή του κώδικα JS για παλαιότερα προγράμματα περιήγησης.
  • Χρησιμοποιούμε ένα πρόσθετο για να εξαγάγουμε όλα τα CSS που αναφέρονται από τα αρχεία JS και να τα συνδυάζουμε σε ένα μέρος. Θα τον αποκαλώ δικό μας πακέτο css.

Μπορεί να έχετε παρατηρήσει περίεργα ονόματα αρχείων πακέτων '[name].[contenthash].ext'. Περιέχουν αντικαταστάσεις ονομάτων αρχείου webpack: [name] θα αντικατασταθεί με το όνομα του σημείου εισόδου (στην περίπτωσή μας, αυτό game), και [contenthash] θα αντικατασταθεί με έναν κατακερματισμό των περιεχομένων του αρχείου. Το κάνουμε βελτιστοποίηση του έργου για κατακερματισμό - μπορείτε να πείτε στα προγράμματα περιήγησης να αποθηκεύουν προσωρινά τα πακέτα JS μας επ' αόριστον, επειδή αν αλλάξει ένα πακέτο, αλλάζει και το όνομα του αρχείου του (αλλαγές contenthash). Το τελικό αποτέλεσμα θα είναι το όνομα του αρχείου προβολής game.dbeee76e91a97d0c7207.js.

αρχείο webpack.common.js είναι το βασικό αρχείο ρυθμίσεων που εισάγουμε στις διαμορφώσεις ανάπτυξης και ολοκληρωμένου έργου. Ακολουθεί ένα παράδειγμα διαμόρφωσης ανάπτυξης:

webpack.dev.js

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

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

Για αποτελεσματικότητα, χρησιμοποιούμε στη διαδικασία ανάπτυξης webpack.dev.js, και αλλάζει σε webpack.prod.jsγια τη βελτιστοποίηση των μεγεθών συσκευασίας κατά την ανάπτυξη στην παραγωγή.

Τοπική ρύθμιση

Συνιστώ να εγκαταστήσετε το έργο σε ένα τοπικό μηχάνημα, ώστε να μπορείτε να ακολουθήσετε τα βήματα που αναφέρονται σε αυτήν την ανάρτηση. Η εγκατάσταση είναι απλή: πρώτα, το σύστημα πρέπει να έχει εγκατασταθεί Κόμβος и NPM. Στη συνέχεια πρέπει να κάνετε

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

και είσαι έτοιμος να φύγεις! Για να ξεκινήσετε τον διακομιστή ανάπτυξης, απλώς εκτελέστε

$ npm run develop

και μεταβείτε στο πρόγραμμα περιήγησης ιστού localhost: 3000. Ο διακομιστής ανάπτυξης θα αναδημιουργήσει αυτόματα τα πακέτα JS και CSS καθώς αλλάζει ο κώδικας - απλώς ανανεώστε τη σελίδα για να δείτε όλες τις αλλαγές!

3. Σημεία Εισόδου Πελάτη

Ας πάμε στον ίδιο τον κώδικα του παιχνιδιού. Πρώτα χρειαζόμαστε μια σελίδα index.html, όταν επισκέπτεστε τον ιστότοπο, το πρόγραμμα περιήγησης θα το φορτώσει πρώτα. Η σελίδα μας θα είναι αρκετά απλή:

index.html

Ένα παράδειγμα παιχνιδιού .io  ΠΑΙΖΩ

Αυτό το παράδειγμα κώδικα έχει απλοποιηθεί ελαφρώς για λόγους σαφήνειας, και θα κάνω το ίδιο με πολλά από τα άλλα παραδείγματα αναρτήσεων. Μπορείτε πάντα να δείτε τον πλήρη κωδικό στη διεύθυνση Github.

Εχουμε:

  • Στοιχείο καμβά HTML5 (<canvas>) που θα χρησιμοποιήσουμε για να αποδώσουμε το παιχνίδι.
  • <link> για να προσθέσουμε το πακέτο μας CSS.
  • <script> για να προσθέσουμε το πακέτο Javascript μας.
  • Κύριο μενού με όνομα χρήστη <input> και το κουμπί PLAY (<button>).

Μετά τη φόρτωση της αρχικής σελίδας, το πρόγραμμα περιήγησης θα ξεκινήσει να εκτελεί κώδικα Javascript, ξεκινώντας από το αρχείο JS σημείου εισόδου: 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);
  };
});

Αυτό μπορεί να ακούγεται περίπλοκο, αλλά δεν συμβαίνουν πολλά εδώ:

  1. Εισαγωγή πολλών άλλων αρχείων JS.
  2. Εισαγωγή CSS (έτσι το Webpack ξέρει να τα περιλαμβάνει στο πακέτο CSS μας).
  3. Запуск connect() για να δημιουργήσετε μια σύνδεση με τον διακομιστή και να εκτελέσετε downloadAssets() για να κατεβάσετε τις εικόνες που απαιτούνται για την απόδοση του παιχνιδιού.
  4. Μετά την ολοκλήρωση του σταδίου 3 εμφανίζεται το κύριο μενού (playMenu).
  5. Ρύθμιση του χειριστή για το πάτημα του κουμπιού "PLAY". Όταν πατηθεί το κουμπί, ο κωδικός αρχικοποιεί το παιχνίδι και λέει στον διακομιστή ότι είμαστε έτοιμοι να παίξουμε.

Το κύριο «κρέας» της λογικής πελάτη-διακομιστή μας βρίσκεται σε εκείνα τα αρχεία που εισήχθησαν από το αρχείο index.js. Τώρα θα τα εξετάσουμε όλα με τη σειρά.

4. Ανταλλαγή δεδομένων πελατών

Σε αυτό το παιχνίδι, χρησιμοποιούμε μια γνωστή βιβλιοθήκη για να επικοινωνήσουμε με τον διακομιστή socket.io. Το Socket.io έχει εγγενή υποστήριξη WebSockets, τα οποία είναι κατάλληλα για αμφίδρομη επικοινωνία: μπορούμε να στείλουμε μηνύματα στον διακομιστή и ο διακομιστής μπορεί να μας στείλει μηνύματα στην ίδια σύνδεση.

Θα έχουμε ένα αρχείο src/client/networking.jsπου θα φροντίσει όλα επικοινωνία με τον διακομιστή:

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

Αυτός ο κωδικός έχει επίσης συντομευτεί ελαφρώς για λόγους σαφήνειας.

Υπάρχουν τρεις κύριες ενέργειες σε αυτό το αρχείο:

  • Προσπαθούμε να συνδεθούμε στον διακομιστή. connectedPromise επιτρέπεται μόνο όταν έχουμε δημιουργήσει μια σύνδεση.
  • Εάν η σύνδεση είναι επιτυχής, καταχωρούμε λειτουργίες επανάκλησης (processGameUpdate() и onGameOver()) για μηνύματα που μπορούμε να λάβουμε από τον διακομιστή.
  • Εξάγουμε play() и updateDirection()ώστε να μπορούν να τα χρησιμοποιούν άλλα αρχεία.

5. Απόδοση πελάτη

Ήρθε η ώρα να εμφανίσετε την εικόνα στην οθόνη!

…αλλά πριν μπορέσουμε να το κάνουμε αυτό, πρέπει να κατεβάσουμε όλες τις εικόνες (πόρους) που χρειάζονται για αυτό. Ας γράψουμε έναν διαχειριστή πόρων:

περιουσιακά στοιχεία.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];

Η διαχείριση πόρων δεν είναι τόσο δύσκολο να εφαρμοστεί! Η κύρια ιδέα είναι να αποθηκεύσετε ένα αντικείμενο assets, το οποίο θα συνδέσει το κλειδί του ονόματος αρχείου με την τιμή του αντικειμένου Image. Όταν φορτωθεί ο πόρος, τον αποθηκεύουμε σε ένα αντικείμενο assets για γρήγορη πρόσβαση στο μέλλον. Πότε θα επιτρέπεται η λήψη κάθε μεμονωμένου πόρου (δηλαδή, όλα πόρους), επιτρέπουμε downloadPromise.

Μετά τη λήψη των πόρων, μπορείτε να ξεκινήσετε την απόδοση. Όπως είπαμε νωρίτερα, για να σχεδιάσουμε σε μια ιστοσελίδα, χρησιμοποιούμε Καμβάς HTML5 (<canvas>). Το παιχνίδι μας είναι αρκετά απλό, οπότε χρειάζεται να σχεδιάσουμε μόνο τα εξής:

  1. Ιστορικό
  2. Σκάφος παίκτη
  3. Άλλοι παίκτες στο παιχνίδι
  4. κοχύλια

Εδώ είναι τα σημαντικά αποσπάσματα src/client/render.js, που αποδίδουν ακριβώς τα τέσσερα στοιχεία που αναφέρονται παραπάνω:

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

Αυτός ο κωδικός συντομεύεται επίσης για λόγους σαφήνειας.

render() είναι η κύρια λειτουργία αυτού του αρχείου. startRendering() и stopRendering() ελέγξτε την ενεργοποίηση του βρόχου απόδοσης στα 60 FPS.

Συγκεκριμένες υλοποιήσεις μεμονωμένων βοηθητικών λειτουργιών απόδοσης (π.χ. renderBullet()) δεν είναι τόσο σημαντικά, αλλά εδώ είναι ένα απλό παράδειγμα:

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

Σημειώστε ότι χρησιμοποιούμε τη μέθοδο getAsset(), που είχε προηγουμένως δει στο asset.js!

Εάν ενδιαφέρεστε να μάθετε για άλλους βοηθούς απόδοσης, διαβάστε τα υπόλοιπα. src/client/render.js.

6. Εισαγωγή πελάτη

Ήρθε η ώρα να φτιάξουμε ένα παιχνίδι παικτός! Το σχέδιο ελέγχου θα είναι πολύ απλό: για να αλλάξετε την κατεύθυνση κίνησης, μπορείτε να χρησιμοποιήσετε το ποντίκι (σε ​​υπολογιστή) ή να αγγίξετε την οθόνη (σε φορητή συσκευή). Για να το εφαρμόσουμε αυτό, θα εγγραφούμε Ακροατές εκδηλώσεων για εκδηλώσεις ποντικιού και αφής.
Θα φροντίσει για όλα αυτά 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() είναι Ακροατές Εκδηλώσεων που καλούν updateDirection() (από networking.js) όταν συμβαίνει ένα συμβάν εισαγωγής (για παράδειγμα, όταν μετακινείται το ποντίκι). updateDirection() χειρίζεται τα μηνύματα με τον διακομιστή, ο οποίος χειρίζεται το συμβάν εισαγωγής και ενημερώνει την κατάσταση του παιχνιδιού ανάλογα.

7. Κατάσταση πελάτη

Αυτή η ενότητα είναι η πιο δύσκολη στο πρώτο μέρος της ανάρτησης. Μην απογοητεύεστε αν δεν το καταλάβετε την πρώτη φορά που το διαβάσατε! Μπορείτε ακόμη και να το παραλείψετε και να επιστρέψετε σε αυτό αργότερα.

Το τελευταίο κομμάτι του παζλ που χρειάζεται για τη συμπλήρωση του κώδικα πελάτη/διακομιστή είναι κατάσταση. Θυμάστε το απόσπασμα κώδικα από την ενότητα Client Rendering;

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() θα πρέπει να είναι σε θέση να μας δώσει την τρέχουσα κατάσταση του παιχνιδιού στον πελάτη σε οποιαδήποτε χρονική στιγμή βάσει ενημερώσεων που λαμβάνονται από τον διακομιστή. Ακολουθεί ένα παράδειγμα ενημέρωσης παιχνιδιού που μπορεί να στείλει ο διακομιστής:

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

Κάθε ενημέρωση παιχνιδιού περιέχει πέντε ίδια πεδία:

  • t: Η χρονική σήμανση διακομιστή που υποδεικνύει πότε δημιουργήθηκε αυτή η ενημέρωση.
  • me: Πληροφορίες σχετικά με τη συσκευή αναπαραγωγής που λαμβάνει αυτήν την ενημέρωση.
  • άλλοι: Μια σειρά πληροφοριών για άλλους παίκτες που συμμετέχουν στο ίδιο παιχνίδι.
  • σφαίρες: μια σειρά πληροφοριών σχετικά με βλήματα στο παιχνίδι.
  • leaderboard: Τρέχοντα δεδομένα βαθμολογικού πίνακα. Σε αυτήν την ανάρτηση, δεν θα τα εξετάσουμε.

7.1 Αφελής κατάσταση πελάτη

Αφελής υλοποίηση getCurrentState() μπορεί να επιστρέψει απευθείας μόνο τα δεδομένα της πιο πρόσφατης ενημέρωσης παιχνιδιού.

αφελής-κράτος.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Ωραίο και ξεκάθαρο! Αλλά να ήταν τόσο απλό. Ένας από τους λόγους για τους οποίους αυτή η υλοποίηση είναι προβληματική: περιορίζει τον ρυθμό καρέ απόδοσης στον ρυθμό του ρολογιού του διακομιστή.

Ρυθμός καρέ: αριθμός καρέ (δηλαδή κλήσεις render()) ανά δευτερόλεπτο ή FPS. Τα παιχνίδια συνήθως προσπαθούν να επιτύχουν τουλάχιστον 60 FPS.

Tick ​​Rate: Η συχνότητα με την οποία ο διακομιστής στέλνει ενημερώσεις παιχνιδιού στους πελάτες. Συχνά είναι χαμηλότερο από τον ρυθμό καρέ. Στο παιχνίδι μας, ο διακομιστής λειτουργεί με συχνότητα 30 κύκλων ανά δευτερόλεπτο.

Αν απλώς αποδώσουμε την τελευταία ενημέρωση του παιχνιδιού, τότε το FPS ουσιαστικά δεν θα ξεπεράσει ποτέ τα 30, γιατί δεν λαμβάνουμε ποτέ περισσότερες από 30 ενημερώσεις ανά δευτερόλεπτο από τον διακομιστή. Ακόμα κι αν καλέσουμε render() 60 φορές το δευτερόλεπτο, τότε οι μισές από αυτές τις κλήσεις απλώς θα επαναλάβουν το ίδιο πράγμα, ουσιαστικά χωρίς να κάνουν τίποτα. Ένα άλλο πρόβλημα με την αφελή υλοποίηση είναι ότι επιρρεπείς σε καθυστερήσεις. Με ιδανική ταχύτητα Διαδικτύου, ο πελάτης θα λαμβάνει μια ενημέρωση παιχνιδιού ακριβώς κάθε 33 ms (30 ανά δευτερόλεπτο):

Δημιουργία ενός παιχνιδιού Web για πολλούς παίκτες .io
Δυστυχώς, τίποτα δεν είναι τέλειο. Μια πιο ρεαλιστική εικόνα θα ήταν:
Δημιουργία ενός παιχνιδιού Web για πολλούς παίκτες .io
Η αφελής υλοποίηση είναι πρακτικά η χειρότερη περίπτωση όσον αφορά την καθυστέρηση. Εάν μια ενημέρωση παιχνιδιού ληφθεί με καθυστέρηση 50 ms, τότε πάγκους πελατών επιπλέον 50 ms επειδή εξακολουθεί να αποδίδει την κατάσταση του παιχνιδιού από την προηγούμενη ενημέρωση. Μπορείτε να φανταστείτε πόσο άβολο είναι αυτό για τον παίκτη: το αυθαίρετο φρενάρισμα θα κάνει το παιχνίδι να αισθάνεται σπασμωδικό και ασταθές.

7.2 Βελτιωμένη κατάσταση πελάτη

Θα κάνουμε κάποιες βελτιώσεις στην αφελή υλοποίηση. Πρώτον, χρησιμοποιούμε καθυστέρηση απόδοσης για 100 ms. Αυτό σημαίνει ότι η "τρέχουσα" κατάσταση του πελάτη θα υστερεί πάντα κατά 100ms σε σχέση με την κατάσταση του παιχνιδιού στον διακομιστή. Για παράδειγμα, εάν η ώρα στον διακομιστή είναι 150, τότε ο πελάτης θα αποδώσει την κατάσταση στην οποία βρισκόταν ο διακομιστής εκείνη τη στιγμή 50:

Δημιουργία ενός παιχνιδιού Web για πολλούς παίκτες .io
Αυτό μας δίνει ένα buffer 100ms για να επιβιώσουμε σε απρόβλεπτους χρόνους ενημέρωσης παιχνιδιού:

Δημιουργία ενός παιχνιδιού Web για πολλούς παίκτες .io
Η ανταμοιβή για αυτό θα είναι μόνιμη καθυστέρηση εισόδου για 100 ms. Αυτή είναι μια μικρή θυσία για ομαλό παιχνίδι - οι περισσότεροι παίκτες (ειδικά οι casual παίκτες) δεν θα παρατηρήσουν καν αυτήν την καθυστέρηση. Είναι πολύ πιο εύκολο για τους ανθρώπους να προσαρμοστούν σε μια σταθερή καθυστέρηση 100ms παρά να παίξουν με μια απρόβλεπτη καθυστέρηση.

Μπορούμε επίσης να χρησιμοποιήσουμε μια άλλη τεχνική που ονομάζεται πρόβλεψη από την πλευρά του πελάτη, το οποίο κάνει καλή δουλειά στη μείωση του αντιληπτού λανθάνοντος χρόνου, αλλά δεν θα καλυφθεί σε αυτήν την ανάρτηση.

Μια άλλη βελτίωση που χρησιμοποιούμε είναι γραμμική παρεμβολή. Λόγω της καθυστέρησης απόδοσης, συνήθως είμαστε τουλάχιστον μία ενημέρωση πριν από την τρέχουσα ώρα στον πελάτη. Όταν καλείται getCurrentState(), μπορούμε να εκτελέσουμε γραμμική παρεμβολή μεταξύ των ενημερώσεων παιχνιδιού λίγο πριν και μετά την τρέχουσα ώρα στον πελάτη:

Δημιουργία ενός παιχνιδιού Web για πολλούς παίκτες .io
Αυτό λύνει το πρόβλημα του ρυθμού καρέ: μπορούμε πλέον να αποδώσουμε μοναδικά καρέ με όποιο ρυθμό καρέ θέλουμε!

7.3 Εφαρμογή βελτιωμένης κατάστασης πελάτη

Παράδειγμα εφαρμογής σε src/client/state.js χρησιμοποιεί τόσο καθυστέρηση απόδοσης όσο και γραμμική παρεμβολή, αλλά όχι για πολύ. Ας χωρίσουμε τον κώδικα σε δύο μέρη. Εδώ είναι το πρώτο:

state.js μέρος 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;
}

Το πρώτο βήμα είναι να καταλάβουμε τι currentServerTime(). Όπως είδαμε νωρίτερα, κάθε ενημέρωση παιχνιδιού περιλαμβάνει μια χρονική σήμανση διακομιστή. Θέλουμε να χρησιμοποιήσουμε την καθυστέρηση απόδοσης για να αποδώσουμε την εικόνα 100 ms πίσω από τον διακομιστή, αλλά δεν θα μάθουμε ποτέ την τρέχουσα ώρα στον διακομιστή, επειδή δεν μπορούμε να γνωρίζουμε πόσος χρόνος χρειάστηκε για να λάβουμε κάποια από τις ενημερώσεις. Το Διαδίκτυο είναι απρόβλεπτο και η ταχύτητά του μπορεί να διαφέρει πολύ!

Για να ξεπεράσουμε αυτό το πρόβλημα, μπορούμε να χρησιμοποιήσουμε μια λογική προσέγγιση: εμείς προσποιηθείτε ότι η πρώτη ενημέρωση έφτασε αμέσως. Αν αυτό ήταν αλήθεια, τότε θα γνωρίζαμε την ώρα του διακομιστή αυτή τη συγκεκριμένη στιγμή! Αποθηκεύουμε τη χρονική σήμανση του διακομιστή firstServerTimestamp και κρατήστε μας τοπικός (πελάτης) χρονική σήμανση την ίδια στιγμή μέσα gameStart.

Αα περίμενε. Δεν θα έπρεπε να είναι χρόνος διακομιστή = ώρα πελάτη; Γιατί κάνουμε διάκριση μεταξύ "χρονοσήμανσης διακομιστή" και "χρονοσήμανσης πελάτη"; Αυτή είναι μια μεγάλη ερώτηση! Αποδεικνύεται ότι δεν είναι το ίδιο πράγμα. Date.now() θα επιστρέψει διαφορετικές χρονικές σημάνσεις στον πελάτη και στο διακομιστή και εξαρτάται από παράγοντες τοπικούς σε αυτά τα μηχανήματα. Μην υποθέτετε ποτέ ότι οι χρονικές σημάνσεις θα είναι ίδιες σε όλα τα μηχανήματα.

Τώρα καταλαβαίνουμε τι κάνει currentServerTime(): επιστρέφει τη χρονική σήμανση διακομιστή του τρέχοντος χρόνου απόδοσης. Με άλλα λόγια, αυτή είναι η τρέχουσα ώρα του διακομιστή (firstServerTimestamp <+ (Date.now() - gameStart)) μείον καθυστέρηση απόδοσης (RENDER_DELAY).

Τώρα ας ρίξουμε μια ματιά στον τρόπο με τον οποίο χειριζόμαστε τις ενημερώσεις παιχνιδιών. Όταν λαμβάνεται από το διακομιστή ενημέρωσης, καλείται processGameUpdate()και αποθηκεύουμε τη νέα ενημέρωση σε έναν πίνακα gameUpdates. Στη συνέχεια, για να ελέγξουμε τη χρήση της μνήμης, αφαιρούμε όλες τις παλιές ενημερώσεις πριν ενημέρωση βάσηςγιατί δεν τους χρειαζόμαστε πια.

Τι είναι η "βασική ενημέρωση"; Αυτό την πρώτη ενημέρωση που βρίσκουμε μετακινώντας προς τα πίσω από την τρέχουσα ώρα του διακομιστή. Θυμάστε αυτό το διάγραμμα;

Δημιουργία ενός παιχνιδιού Web για πολλούς παίκτες .io
Η ενημέρωση παιχνιδιού ακριβώς στα αριστερά του "Client Render Time" είναι η βασική ενημέρωση.

Σε τι χρησιμεύει η ενημέρωση βάσης; Γιατί μπορούμε να ρίχνουμε τις ενημερώσεις στη γραμμή βάσης; Για να το καταλάβουμε αυτό, ας τελικά εξετάσει την εφαρμογή getCurrentState():

state.js μέρος 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),
    };
  }
}

Αναλαμβάνουμε τρεις περιπτώσεις:

  1. base < 0 σημαίνει ότι δεν υπάρχουν ενημερώσεις μέχρι τον τρέχοντα χρόνο απόδοσης (βλ. παραπάνω υλοποίηση getBaseUpdate()). Αυτό μπορεί να συμβεί ακριβώς στην αρχή του παιχνιδιού λόγω καθυστέρησης απόδοσης. Σε αυτήν την περίπτωση, χρησιμοποιούμε την τελευταία ενημέρωση που λάβαμε.
  2. base είναι η τελευταία ενημέρωση που έχουμε. Αυτό μπορεί να οφείλεται σε καθυστέρηση δικτύου ή κακή σύνδεση στο Διαδίκτυο. Σε αυτήν την περίπτωση, χρησιμοποιούμε επίσης την πιο πρόσφατη ενημέρωση που έχουμε.
  3. Έχουμε μια ενημέρωση τόσο πριν όσο και μετά τον τρέχοντα χρόνο απόδοσης, οπότε μπορούμε παρεμβάλλω!

Ό,τι έχει μείνει μέσα state.js είναι μια εφαρμογή γραμμικής παρεμβολής που είναι απλά (αλλά βαρετά) μαθηματικά. Αν θέλετε να το εξερευνήσετε μόνοι σας, τότε ανοίξτε state.js επί Github.

Μέρος 2. Διακομιστής υποστήριξης

Σε αυτό το μέρος, θα ρίξουμε μια ματιά στο backend του Node.js που ελέγχει το δικό μας Παράδειγμα παιχνιδιού .io.

1. Σημείο εισόδου διακομιστή

Για τη διαχείριση του διακομιστή ιστού, θα χρησιμοποιήσουμε ένα δημοφιλές πλαίσιο ιστού για το Node.js που ονομάζεται Εxpress. Θα ρυθμιστεί από το αρχείο σημείου εισόδου του διακομιστή μας src/server/server.js:

server.js μέρος 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}`);

Θυμάστε ότι στο πρώτο μέρος συζητήσαμε το Webpack; Εδώ θα χρησιμοποιήσουμε τις διαμορφώσεις του Webpack μας. Θα τα χρησιμοποιήσουμε με δύο τρόπους:

  • Χρήση webpack-dev-middleware για να αναδημιουργήσουμε αυτόματα τα πακέτα ανάπτυξης μας ή
  • στατική μεταφορά φακέλου dist/, στο οποίο το Webpack θα γράψει τα αρχεία μας μετά την κατασκευή της παραγωγής.

Ένα άλλο σημαντικό έργο server.js είναι να ρυθμίσετε τον διακομιστή socket.ioπου μόλις συνδέεται με τον διακομιστή Express:

server.js μέρος 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);
});

Μετά την επιτυχή δημιουργία μιας σύνδεσης socket.io με τον διακομιστή, ρυθμίσαμε προγράμματα χειρισμού συμβάντων για τη νέα υποδοχή. Οι χειριστές συμβάντων χειρίζονται μηνύματα που λαμβάνονται από πελάτες με ανάθεση σε ένα αντικείμενο singleton game:

server.js μέρος 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);
}

Δημιουργούμε ένα παιχνίδι .io, οπότε χρειαζόμαστε μόνο ένα αντίγραφο Game ("Παιχνίδι") - όλοι οι παίκτες παίζουν στην ίδια αρένα! Στην επόμενη ενότητα, θα δούμε πώς λειτουργεί αυτή η τάξη. Game.

2. Διακομιστές παιχνιδιών

Κατηγορία Game περιέχει την πιο σημαντική λογική από την πλευρά του διακομιστή. Έχει δύο βασικά καθήκοντα: διαχείριση παικτών и προσομοίωση παιχνιδιού.

Ας ξεκινήσουμε με το πρώτο καθήκον, τη διαχείριση παικτών.

game.js μέρος 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);
    }
  }

  // ...
}

Σε αυτό το παιχνίδι, θα αναγνωρίσουμε τους παίκτες από το γήπεδο id την υποδοχή τους socket.io (αν μπερδευτείτε, επιστρέψτε στην server.js). Το ίδιο το Socket.io εκχωρεί σε κάθε υποδοχή ένα μοναδικό idοπότε δεν χρειάζεται να ανησυχούμε για αυτό. θα του τηλεφωνήσω Αναγνωριστικό παίκτη.

Έχοντας αυτό κατά νου, ας εξερευνήσουμε τις μεταβλητές παράδειγμα σε μια τάξη Game:

  • sockets είναι ένα αντικείμενο που συνδέει το αναγνωριστικό της συσκευής αναπαραγωγής στην υποδοχή που σχετίζεται με τη συσκευή αναπαραγωγής. Μας επιτρέπει να έχουμε πρόσβαση στις υποδοχές με τα αναγνωριστικά παίκτη τους σε σταθερό χρόνο.
  • players είναι ένα αντικείμενο που δεσμεύει το αναγνωριστικό του παίκτη με τον κωδικό>Αντικείμενο αναπαραγωγής

bullets είναι μια συστοιχία αντικειμένων Bullet, που δεν έχει συγκεκριμένη σειρά.
lastUpdateTime είναι η χρονική σήμανση της τελευταίας φοράς που ενημερώθηκε το παιχνίδι. Θα δούμε πώς θα χρησιμοποιηθεί σύντομα.
shouldSendUpdate είναι μια βοηθητική μεταβλητή. Σύντομα θα δούμε και τη χρήση του.
Μέθοδοι addPlayer(), removePlayer() и handleInput() δεν χρειάζεται να εξηγήσω, χρησιμοποιούνται σε server.js. Εάν θέλετε να ανανεώσετε τη μνήμη σας, πηγαίνετε λίγο πιο πίσω.

Τελευταία γραμμή constructor() ξεκινά κύκλος ενημέρωσης παιχνίδια (με συχνότητα 60 ενημερώσεις / s):

game.js μέρος 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;
    }
  }

  // ...
}

Μέθοδος update() περιέχει ίσως το πιο σημαντικό κομμάτι της λογικής από την πλευρά του διακομιστή. Να τι κάνει, με τη σειρά:

  1. Υπολογίζει πόσο καιρό dt πέρασε από την τελευταία update().
  2. Ανανεώνει κάθε βλήμα και το καταστρέφει εάν χρειάζεται. Θα δούμε την υλοποίηση αυτής της λειτουργικότητας αργότερα. Προς το παρόν, αρκεί να το γνωρίζουμε bullet.update() επιστρέφει trueεάν το βλήμα έπρεπε να καταστραφεί (βγήκε από την αρένα).
  3. Ενημερώνει κάθε παίκτη και δημιουργεί ένα βλήμα εάν είναι απαραίτητο. Θα δούμε επίσης αυτήν την εφαρμογή αργότερα - player.update() μπορεί να επιστρέψει ένα αντικείμενο Bullet.
  4. Έλεγχοι για συγκρούσεις μεταξύ βλημάτων και παικτών με applyCollisions(), το οποίο επιστρέφει μια σειρά από βλήματα που χτυπούν τους παίκτες. Για κάθε βλήμα που επιστρέφεται, αυξάνουμε τους πόντους του παίκτη που το εκτόξευσε (χρησιμοποιώντας player.onDealtDamage()) και, στη συνέχεια, αφαιρέστε το βλήμα από τη συστοιχία bullets.
  5. Ειδοποιεί και καταστρέφει όλους τους σκοτωμένους παίκτες.
  6. Στέλνει μια ενημέρωση παιχνιδιού σε όλους τους παίκτες κάθε δευτερόλεπτο φορές όταν καλείται update(). Αυτό μας βοηθά να παρακολουθούμε τη βοηθητική μεταβλητή που αναφέρεται παραπάνω. shouldSendUpdate... Οπως και update() καλείται 60 φορές/δευτερόλεπτα, στέλνουμε ενημερώσεις παιχνιδιού 30 φορές/δευτερόλεπτα. Ετσι, συχνότητα ρολογιού Το ρολόι του διακομιστή είναι 30 ρολόγια/δευτερόλεπτα (μιλήσαμε για ρυθμούς ρολογιού στο πρώτο μέρος).

Γιατί να στέλνετε μόνο ενημερώσεις παιχνιδιών μέσα στο χρόνο ? Για αποθήκευση καναλιού. 30 ενημερώσεις παιχνιδιών ανά δευτερόλεπτο είναι πολλές!

Γιατί όχι απλά τηλεφωνήστε update() 30 φορές το δευτερόλεπτο; Για να βελτιώσετε την προσομοίωση του παιχνιδιού. Το πιο συχνά αποκαλούμενο update(), τόσο πιο ακριβής θα είναι η προσομοίωση του παιχνιδιού. Αλλά μην παρασυρθείτε πολύ με τον αριθμό των προκλήσεων. update(), γιατί πρόκειται για μια υπολογιστικά ακριβή εργασία - 60 ανά δευτερόλεπτο είναι αρκετά.

Η υπόλοιπη τάξη Game αποτελείται από βοηθητικές μεθόδους που χρησιμοποιούνται σε update():

game.js μέρος 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() αρκετά απλό - ταξινομεί τους παίκτες ανά βαθμολογία, παίρνει τους πέντε πρώτους και επιστρέφει το όνομα χρήστη και τη βαθμολογία για τον καθένα.

createUpdate() χρησιμοποιείται σε update() για να δημιουργήσετε ενημερώσεις παιχνιδιών που διανέμονται στους παίκτες. Το κύριο καθήκον του είναι να καλεί μεθόδους serializeForUpdate()υλοποιείται για τάξεις Player и Bullet. Σημειώστε ότι μεταβιβάζει δεδομένα μόνο σε κάθε παίκτη πλησιέστερος παίκτες και βλήματα - δεν χρειάζεται να μεταδώσετε πληροφορίες σχετικά με αντικείμενα παιχνιδιού που βρίσκονται μακριά από τον παίκτη!

3. Αντικείμενα παιχνιδιού στον διακομιστή

Στο παιχνίδι μας, τα βλήματα και οι παίκτες είναι στην πραγματικότητα πολύ παρόμοια: είναι αφηρημένα, στρογγυλά, κινητά αντικείμενα παιχνιδιού. Για να επωφεληθείτε από αυτήν την ομοιότητα μεταξύ παικτών και βλημάτων, ας ξεκινήσουμε με την υλοποίηση της βασικής κλάσης 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,
    };
  }
}

Δεν συμβαίνει τίποτα περίπλοκο εδώ. Αυτή η τάξη θα είναι ένα καλό σημείο αγκύρωσης για την επέκταση. Ας δούμε πώς η τάξη Bullet χρήσεις 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;
  }
}

Реализация Bullet πολύ κοντό! Έχουμε προσθέσει σε Object μόνο οι ακόλουθες επεκτάσεις:

  • Χρησιμοποιώντας ένα πακέτο κοντός για τυχαία γενιά id βλήμα.
  • Προσθήκη πεδίου parentIDώστε να μπορείτε να παρακολουθείτε τον παίκτη που δημιούργησε αυτό το βλήμα.
  • Προσθήκη τιμής επιστροφής σε update(), που ισούται με trueεάν το βλήμα είναι έξω από την αρένα (θυμάστε ότι μιλήσαμε για αυτό στην τελευταία ενότητα;).

Ας προχωρήσουμε στο 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,
    };
  }
}

Οι παίκτες είναι πιο περίπλοκοι από τα βλήματα, επομένως μερικά ακόμη πεδία θα πρέπει να αποθηκευτούν σε αυτήν την κατηγορία. Η μέθοδός του update() κάνει πολλή δουλειά, συγκεκριμένα, επιστρέφει το νεοδημιουργημένο βλήμα αν δεν έχει μείνει κανένα fireCooldown (θυμάστε ότι μιλήσαμε για αυτό στην προηγούμενη ενότητα;). Επίσης επεκτείνει τη μέθοδο serializeForUpdate(), γιατί πρέπει να συμπεριλάβουμε επιπλέον πεδία για τον παίκτη στην ενημέρωση του παιχνιδιού.

Έχοντας μια βασική τάξη Object - ένα σημαντικό βήμα για την αποφυγή επανάληψης κώδικα. Για παράδειγμα, καμία τάξη Object κάθε αντικείμενο παιχνιδιού πρέπει να έχει την ίδια υλοποίηση distanceTo(), και η αντιγραφή-επικόλληση όλων αυτών των υλοποιήσεων σε πολλά αρχεία θα ήταν εφιάλτης. Αυτό γίνεται ιδιαίτερα σημαντικό για μεγάλα έργα.όταν ο αριθμός των επεκτεινόμενων Object οι τάξεις αυξάνονται.

4. Ανίχνευση σύγκρουσης

Το μόνο που μας μένει είναι να αναγνωρίσουμε πότε τα βλήματα χτύπησαν τους παίκτες! Θυμηθείτε αυτό το κομμάτι κώδικα από τη μέθοδο update() στην τάξη Game:

παιχνίδι.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),
    );

    // ...
  }
}

Πρέπει να εφαρμόσουμε τη μέθοδο applyCollisions(), το οποίο επιστρέφει όλα τα βλήματα που χτυπούν τους παίκτες. Ευτυχώς, δεν είναι τόσο δύσκολο να γίνει γιατί

  • Όλα τα αντικείμενα που συγκρούονται είναι κύκλοι, το οποίο είναι το απλούστερο σχήμα για την εφαρμογή ανίχνευσης σύγκρουσης.
  • Έχουμε ήδη μια μέθοδο distanceTo(), το οποίο εφαρμόσαμε στην προηγούμενη ενότητα στην τάξη Object.

Δείτε πώς φαίνεται η εφαρμογή μας για τον εντοπισμό σύγκρουσης:

συγκρούσεις.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;
}

Αυτή η απλή ανίχνευση σύγκρουσης βασίζεται στο γεγονός ότι δύο κύκλοι συγκρούονται αν η απόσταση μεταξύ των κέντρων τους είναι μικρότερη από το άθροισμα των ακτίνων τους. Εδώ είναι η περίπτωση όπου η απόσταση μεταξύ των κέντρων δύο κύκλων είναι ακριβώς ίση με το άθροισμα των ακτίνων τους:

Δημιουργία ενός παιχνιδιού Web για πολλούς παίκτες .io
Υπάρχουν μερικές ακόμη πτυχές που πρέπει να λάβετε υπόψη εδώ:

  • Το βλήμα δεν πρέπει να χτυπήσει τον παίκτη που το δημιούργησε. Αυτό μπορεί να επιτευχθεί με σύγκριση bullet.parentID с player.id.
  • Το βλήμα πρέπει να χτυπήσει μόνο μία φορά στην περιοριστική περίπτωση πολλαπλών παικτών που συγκρούονται ταυτόχρονα. Θα λύσουμε αυτό το πρόβλημα χρησιμοποιώντας τον χειριστή break: μόλις βρεθεί ο παίκτης που συγκρούεται με το βλήμα, σταματάμε την αναζήτηση και προχωράμε στο επόμενο βλήμα.

Τέλος

Αυτό είναι όλο! Καλύψαμε όλα όσα πρέπει να γνωρίζετε για να δημιουργήσετε ένα παιχνίδι ιστού .io. Τι έπεται? Δημιουργήστε το δικό σας παιχνίδι .io!

Όλο το δείγμα κώδικα είναι ανοιχτού κώδικα και δημοσιεύεται στο Github.

Πηγή: www.habr.com

Προσθέστε ένα σχόλιο