📜 ⬆️ ⬇️

WebGL game development features for Digital Trip

image

Hi, Habr! In this article I want to share my own experience developing the WebGL game Digital Trip. In addition to WebGL, the game uses technologies such as WebAudio API, WebSockets, getUserMedia, Vibration API, DeviceOrientation, as well as the three.js libraries, hedtrackr.js, socket.io, etc. The article will describe the most interesting implementation details. I will talk about the game engine, control with a mobile, webcam control, I will say a few words about the back-end on node.js, which works in conjunction with the dogecoin daemon.
At the end of the article are links to used libraries, source code on GitHub, a description of the game and the game itself.
All who are interested, please under the cat.


The gameplay is very simple: we fly along a predetermined trajectory, collect coins and bonuses, dodge stones. Player positions are limited to 3 options. Bonuses come in three types: shield (HTML5), slowdown (cat), or restoration of lives (lips). At the end of the game, you can withdraw the received coins to your dogecoin wallet.
')
image

The goal of the game development is to tell about the browser capabilities, to upgrade your skills, share experience and get great pleasure from the process.
Now more about the features of implementation.

Game engine and some details


The global variable DT is used as a namespace, with which you can access service functions, class constructors and instances, as well as handler functions, various parameters, etc.

Preloader

Three scripts are connected to the village:
<script src="js/vendor/jquery.min.js"></script> <script src="js/vendor/yepnope.1.5.4-min.js"></script> <script src="js/myYepnope.min.js"></script> 

To download the remaining scripts, the yepnope resource loader is used.
When myYepnope.js is executed, the browser checks for WebGL support:
 var isWebGLSupported, canvas = document.getElementById('checkwebgl'); if (!window.WebGLRenderingContext) { // Browser has no idea what WebGL is isWebGLSupported = false; } else if (canvas.getContext("webgl") || canvas.getContext("webGlCanvas") || canvas.getContext("moz-webgl") || canvas.getContext("webkit-3d") || canvas.getContext("experimental-webgl")) { // Can get context isWebGLSupported = true; } else { // Can't get context isWebGLSupported = false; } 

If the browser supports WebGL, myYepnope defines a function to display the loading of resources and loads other scripts.
This is where the preloader starts. Visually, it represents a blurry start-up game interface with a subsequent decrease in the blur radius as it loads.


The blur effect is achieved by using the css -webkit-filter: blur() properties. The property is perfectly animated. Firefox uses svg filter, the radius of which is dynamically changed and used as a css filter: 'url()' property filter: 'url()' , while the data url generated by the script and updated every 20% of the load.
Code
 if (isWebGLSupported) { var $body = $('body'), $cc = $('.choose_control'), maxBlur = 100, steps = 4, isWebkitBlurSupported; if ($body[0].style.webkitFilter === undefined) { isWebkitBlurSupported = false; $cc.css({filter: "url('data:image/svg+xml;utf8,<svg xmlns=\"http://www.w3.org/2000/svg\"><filter id=\"blur-overlay\"><feGaussianBlur stdDeviation=\"" + maxBlur + "\"/></filter></svg>#blur-overlay')"}); } else { isWebkitBlurSupported = true; $body[0].style.webkitFilter = 'blur(' + maxBlur + 'px)'; } $('#loader').css({display: 'table'}); $cc.css({display: 'table'}); yepnope.loadCounter = 0; yepnope.percent = 0; yepnope.showLoading = function (n) { yepnope.percent += maxBlur/steps; yepnope.loadCounter += 1; $(".loader").animate({minWidth: Math.round(yepnope.percent)+"px"}, { duration: 1000, progress: function () { var current = parseInt($(".loader").css("minWidth"), 10) * 100/maxBlur; $("title").html(Math.floor(current) + "% " + "digital trip"); if (isWebkitBlurSupported) { $body[0].style.webkitFilter = 'blur('+ (maxBlur - current)+ 'px)'; } if (!isWebkitBlurSupported && current % 20 === 0) { $cc.css({filter: "url('data:image/svg+xml;utf8,<svg xmlns=\"http://www.w3.org/2000/svg\"><filter id=\"blur-overlay\"><feGaussianBlur stdDeviation=\"" + (maxBlur - maxBlur/(steps+1)*n) + "\"/></filter></svg>#blur-overlay')"}); } if (current === 100) { $("title").html("digital trip"); if (!isWebkitBlurSupported && current % 20 === 0) $cc.css({filter: "url('data:image/svg+xml;utf8,<svg xmlns=\"http://www.w3.org/2000/svg\"><filter id=\"blur-overlay\"><feGaussianBlur stdDeviation=\"" + 0 + "\"/></filter></svg>#blur-overlay')"}); } }, complete: function () { if (n === steps) { DT.runApp(); } } }); }; yepnope([{ load: [ "js/vendor/three.min.js", "js/DT.min.js", "../socket.io/socket.io.js" ], callback: {} }]); } else { $('#nogame').css({display: 'table'}); } 


After downloading, you can choose one of three control methods and start the game.

Developments

Interactions between objects within the game are based on standard and custom events.
List of events
'blur' // loss of focus
'focus' // appearance of focus

'socketInitialized' // initialize socket.io
'externalObjectLoaded' // completion of loading external model

'startGame' // start the game
'pauseGame' // pause
'resumeGame' // resume game
'gameOver' // end of game
'resetGame' // reset game parameters

'updatePath' // update position in game space (pipe)
'update' // update game objects

'changeSpeed' // external speed change
'showHelth' // health change display
'showInvulner' // display invulnerability change (shield)
'showScore' // display of change points
'showFun' // display of the mode deceleration change (cat)
'changeHelth' // health change
'bump' // collision with an object
'blink' // sphere flashing
'hit' // clash with a stone
'changeScore' // change point
'catchBonus' // catch bonus
'makeInvulner' // change invulnerability mode (shield)
'makeFun' // enable slow mode (cat)
'showBonuses' // reflection of caught bonuses
'stopFun' // off the seal mode

'paymentCheck' // client verification status for payment
'paymentMessage' // receive payment message
'transactionMessage' // receive transaction message
'checkup' // start checking

Events occur in the document element, calling the appropriate handlers, for example:
 DT.$document.trigger('gameOver', {cause: 'death'}); DT.$document.on('gameOver', function (e, data) { if (data.cause === 'death') { DT.audio.sounds.gameover.play(); } }); 

The 'blur' and 'focus' events are triggered in a window and are used to turn off the sound and pause when the window with the game loses focus.
 DT.$window.on('blur', function() { if (DT.game.wasStarted && !DT.game.wasPaused && !DT.game.wasOver) { DT.$document.trigger('pauseGame', {}); } DT.setVolume(0); }); 

Initialization of the game world

Here everything is standard for projects on three.js : a scene, camera, game space, light sources, background are created.

Scene
 DT.scene = new THREE.Scene(); 

Camera
 DT.splineCamera = new THREE.PerspectiveCamera( 84, window.innerWidth / window.innerHeight, 0.01, 1000 ); 

Gaming space - pipe, along the TorusKnot curve from the THREE.Curves set
 var extrudePath = new THREE.Curves.TorusKnot(); DT.tube = new THREE.TubeGeometry(extrudePath, 100, 3, 8, true, true); 

Sources of light
 DT.lights = { light: new THREE.PointLight(0xffffff, 0.75, 100), directionalLight: new THREE.DirectionalLight(0xffffff, 0.5) }; 

The background is in the form of a sphere around the playing space with a picture stretched along the inner surface with borders of the same color for a seamless connection.
Background
 var geomBG = new THREE.SphereGeometry(500, 32, 32), matBG = new THREE.MeshBasicMaterial({ map: THREE.ImageUtils.loadTexture('img/background5.jpg'), }), worldBG = new THREE.Mesh(geomBG, matBG); worldBG.material.side = THREE.BackSide; 


Classes

There are several main classes in the game: Game ( DT.Game ), Player ( DT.Player ), and Game Object ( DT.GameObject ). They have their own methods (update, reset, etc.), called by the appropriate handlers in response to the triggering of an event. The game object contains various parameters (speed, acceleration), constants (minimum distance between stones and information about their state ( wasStarted , wasPaused ). Player object contains information about the current state of the player (score, life, invulnerability), as well as the state of the player model (sphere, rings (contours, which are indicators of health) around the sphere.) All other objects are subclasses of the Game object (particles, shield on the player, bonuses).

Internal and external models

There are two types of models in the game: internal (simple) models (sphere, health indicator (rings / outlines), stones and coins), which are created using the three.js means and external (complex) models (bonuses and HTML5 shield around the sphere) are loaded in .obj format appropriate loader .
The sphere is part of the player’s object and consists of 2 objects: the physical sphere for calculating collisions with other objects (not added to the scene)
Sphere
 this.sphere = new THREE.Mesh(new THREE.SphereGeometry(0.5, 32, 32), new THREE.MeshPhongMaterial({})); 

and a Fireworks- based particle system for the Fireworks particle system.



Particle system
 this.emitter = Fireworks.createEmitter({nParticles : 100}) .effectsStackBuilder() .spawnerSteadyRate(30) .position(Fireworks.createShapePoint(0, 0, 0)) .velocity(Fireworks.createShapePoint(0, 0, 0)) .lifeTime(0.2, 0.7) .renderToThreejsParticleSystem({ ... }) .back() .start(); 

Models of bonuses are loaded in the form of 2 objects each with the same number of vertices (also for transformation).

List of models
 DT.listOfModels = [{ name: 'bonusH1', scale: 0.1, rotaion: new THREE.Vector3(0, 0, 0), color: 0xff0000, }, { name: 'bonusI', scale: 0.02, rotaion: new THREE.Vector3(0, 0, 0), color: 0x606060, '5': 0xffffff, 'html': 0xffffff, 'orange': 0xD0671F, 'shield': 0xC35020, }, { name: 'bonusE1', scale: 0.75, rotaion: new THREE.Vector3(0, 0, 0), color: 0x606060, }, { name: 'bonusH2', scale: 0.1, rotaion: new THREE.Vector3(0, 0, 0), color: 0xff0000, }, { name: 'shield', scale: 0.16, rotaion: new THREE.Vector3(0, 0, 0), color: 0x606060, }, { name: 'bonusE2', scale: 0.75, rotaion: new THREE.Vector3(0, 0, 0), color: 0x606060, } ]; 


Loader
 var manager = new THREE.LoadingManager(), loader = new THREE.OBJLoader(manager); manager.onProgress = function (item, loaded, total) { console.info('loaded item', loaded, 'of', total, '('+item+')'); }; DT.listOfModels.forEach(function (el, i, a) { loader.load('objects/' + el.name + '.obj', function ( object ) { object.traverse( function ( child ) { var color = el[child.name] || el.color; child.material = new THREE.MeshPhongMaterial({ color: color, shading: THREE.SmoothShading, emissive: new THREE.Color(color).multiplyScalar(0.5), shininess: 100, }); }); if (i === 1) { a[i].object = object } else { a[i].object = object.children[0]; } DT.$document.trigger('externalObjectLoaded', {index: i}); }); }); 


After loading, external models are available via the link DT.listOfModels[index].object and are used in the bonus constructor.

Transformations and postprocessing

The game has several transformations: for health indicators, for bonuses and a glitch effect (or a broken TV effect) at the end of the game.

Health indicator and bonus transformations are based on morphTargets .



When an object is created, the standard state is saved in the geometry of this object. The remaining states will remain in the special property of the morphTargets geometry. The current state of the object is determined by the level of the morphTargetInfluences object.

The health indicator (rings / contours) around the sphere is 2 objects, the geometry of each of which consists of 180 vertices (60 from the inside and outside).

Rings / contours can be circles, five-, four-, and triangles, while the number of vertices always remains 180.
It is important that the number of vertices in each state is the same, and their coordinate vectors change “correctly” (in accordance with the desired transformation), otherwise the transformation will not work correctly or will not work at all.

To do this, a special function was written to create the geometry of the health indicator (rings / contours).

health indicator geometry
 DT.createGeometry = function (circumradius) { var geometry = new THREE.Geometry(), x, innerradius = circumradius * 0.97, n = 60; function setMainVert (rad, numb) { var vert = []; for (var i = 0; i < numb; i++) { var vec3 = new THREE.Vector3( rad * Math.sin((Math.PI / numb) + (i * ((2 * Math.PI)/ numb))), rad * Math.cos((Math.PI / numb) + (i * ((2 * Math.PI)/ numb))), 0 ); vert.push(vec3); } return vert; } function fillVert (vert) { var nFilled, nUnfilled, result = []; nFilled = vert.length; nUnfilled = n/nFilled; vert.forEach(function (el, i, arr) { var nextInd = i === arr.length - 1 ? 0 : i + 1; var vec = el.clone().sub(arr[nextInd]); for (var j = 0; j < nUnfilled; j++) { result.push(vec.clone().multiplyScalar(1/nUnfilled).add(el)); } }); return result; } // set morph targets [60, 5, 4, 3, 2].forEach(function (el, i, arr) { var vert, vertOuter, vertInner; vertOuter = fillVert(setMainVert(circumradius, el).slice(0)).slice(0); vertInner = fillVert(setMainVert(innerradius, el).slice(0)).slice(0); vert = vertOuter.concat(vertInner); geometry.morphTargets.push({name: 'vert'+i, vertices: vert}); if (i === 0) { geometry.vertices = vert.slice(0); } }); // Generate the faces of the n-gon. for (x = 0; x < n; x++) { var next = x === n - 1 ? 0 : x + 1; geometry.faces.push(new THREE.Face3(x, next, x + n)); geometry.faces.push(new THREE.Face3(x + n, next, next + n)); } return geometry; }; 


For the same reason, bonus models are imported as two .obj objects that were pre-modified in a certain way in the editor (as is necessary for the expected transformation animation (transformation). We used 3ds Max and blender for this.



With the lip model there is one interesting point. In the normal state, the lips are animated (open and close). When this happens, the force of influence of the vertices from two sets of vertices (open and closed lips) simply changes. According to the three.js documentation, the morphTargetInfluence value of each vertex set must be in the range [0, 1]. In this case, when using force greater than 1, the effect of a certain “hyper-influence” occurs. So, for example, if you apply morphTargetInfluence with a value of 5 to the set of vertices for a cat model, the model seems to “turn inside out”. In a lip model, it looks like a “mouth opening”.
The absorption effect of the “lip” bonus is based on this behavior, which made it possible to avoid importing an additional external model.
The glitch effect (or the broken TV effect) used to animate the end of the game is an example of shader-based post-processing.



Create effect
Code
 DT.effectComposer = new THREE.EffectComposer( DT.renderer ); DT.effectComposer.addPass( new THREE.RenderPass( DT.scene, DT.splineCamera ) ); DT.effectComposer.on = false; var badTVParams = { mute:true, show: true, distortion: 3.0, distortion2: 1.0, speed: 0.3, rollSpeed: 0.1 } var badTVPass = new THREE.ShaderPass( THREE.BadTVShader ); badTVPass.on = false; badTVPass.renderToScreen = true; DT.effectComposer.addPass(badTVPass); 


And render it every frame.
Code
 DT.$document.on('update', function (e, data) { if (DT.effectComposer.on) { badTVPass.uniforms[ "distortion" ].value = badTVParams.distortion; badTVPass.uniforms[ "distortion2" ].value = badTVParams.distortion2; badTVPass.uniforms[ "speed" ].value = badTVParams.speed; badTVPass.uniforms[ "rollSpeed" ].value = badTVParams.rollSpeed; DT.effectComposer.render(); badTVParams.distortion+=0.15; badTVParams.distortion2+=0.05; badTVParams.speed+=0.015; badTVParams.rollSpeed+=0.005; }; }); 


Effect is activated after the occurrence of the 'gameOver'
Code
 DT.$document.on('gameOver', function (e, data) { DT.effectComposer.on = true; }); 


And is reset at the corresponding event.
Code
 DT.$document.on('resetGame', function (e, data) { DT.effectComposer.on = false; badTVParams = { distortion: 3.0, distortion2: 1.0, speed: 0.3, rollSpeed: 0.1 } }); 


The use of post-processing significantly increases the frame rendering time, so the post-processing is used for a short time and at the end of the game.

Music visualization

Music is visualized by the pulsation of particles (dust) in the playing space.

For this, the desired visualization frequency was determined. The presence level of the sound of the desired frequency ( DT.audio.valueAudio ) is currently defined in the render buffer as follows.
Code
 var getFrequencyValue = function(frequency, bufferIndex) { if (!DT.isAudioCtxSupp) return; var nyquist = DT.audio.context.sampleRate/2, index = Math.round(frequency/nyquist * freqDomain[bufferIndex].length); return freqDomain[bufferIndex][index]; }; var visualize = function(index) { if (!DT.isAudioCtxSupp) return; freqDomain[index] = new Uint8Array(analysers[index].frequencyBinCount); analysers[index].getByteFrequencyData(freqDomain[index]); DT.audio.valueAudio = getFrequencyValue(DT.audio.frequency[index], index); }; 

The DT.audio.valueAudio value DT.audio.valueAudio used to update the state of particle transparency:
Code
 DT.$document.on('update', function (e, data) { DT.dust.updateMaterial({ isFun: DT.player.isFun, valueAudio: DT.audio.valueAudio, color: DT.player.sphere.material.color }); }); 

The updateMaterial method updateMaterial :
Code
 DT.Dust.prototype.updateMaterial = function (options) { if (!this.material.visible) { this.material.visible = true; } this.material.color = options.isFun ? options.color : new THREE.Color().setRGB(1,0,0); this.material.opacity = 0.5 + options.valueAudio/255; return this; }; 

Read more about the WebAudio API here .

Favicon animation

Favicon in a digital trip is a black and white image of a cat by default.
In deceleration mode (cat mode), the icon starts to change color.
If you can put in Firefox
 <link rel="icon" type="image/gif" href="fav.gif"> 

then in Chrome this method will not work. For Chrome, a dynamic substitution of the favicon png image was used.
The overall implementation looks like this:
Code
 var favicon = document.getElementsByTagName('link')[1], giffav = document.createElement('link'), head = document.getElementsByTagName('head')[0], isChrome = navigator.userAgent.indexOf('Chrome') !== -1; giffav.setAttribute('rel', 'icon'); giffav.setAttribute('type', 'image/gif'); giffav.setAttribute('href', 'img/fav.gif'); DT.$document.on('update', function (e, data) { if (isChrome && DT.player.isFun && DT.animate.id % 10 === 0) favicon.setAttribute('href', 'img/' + (DT.animate.id % 18 + 1) + '.png'); }); DT.$document.on('showFun', function (e, data) { if (!data.isFun) { if (isChrome) { favicon.setAttribute('href', 'img/0.png'); } else { $(giffav).remove(); head.appendChild(favicon); } } else { if (!isChrome) { $(favicon).remove(); head.appendChild(giffav); } } }); 

'update' is the event of updating the state of objects, 'showFun' is the event of the start of the seal mode (deceleration), DT.player.isFun is the state of the seal mode, DT.animate.id is the number of the current frame (frame). The total number of possible favicon options is 19. Unfortunately, there is no favicon animation in Safari.

Mobile controller


The game has the ability to control a mobile device.
To connect a mobile device as a controller, click on the link or use the QR code generated by the plugin .

Control is performed using a gyro and the 'deviceOrientation' event. In the absence of a gyroscope or access to it, use the control by pressing the control buttons.

Fallback and handler:
Code
 // Technique from Juriy Zaytsev // http://thinkweb2.com/projects/prototype/detecting-event-support-without-browser-sniffing/ var eventSupported = function( eventName ) { var el = document.createElement("div"); eventName = "on" + eventName; var isSupported = (eventName in el); if ( !isSupported ) { el.setAttribute(eventName, "return;"); isSupported = typeof el[eventName] === "function"; } el = null; return isSupported; }; // device orientation function orientationTest (event) { if (!turned && event.gamma) turned = true; window.removeEventListener('deviceorientation', orientationTest, false); window.removeEventListener('MozOrientation', orientationTest, false); } window.addEventListener('deviceorientation', orientationTest, false); window.addEventListener('MozOrientation', orientationTest, false); setTimeout(function () { if (!turned) { $("#btnLeft").on('touchstart',function () { socket.emit("click", {"click":"toTheLeft"}); }); $("#btnRight").on('touchstart',function () { socket.emit("click", {"click":"toTheRight"}); }); $status.html("push buttons to control"); } else { $status.html("tilt your device to control"); } if (!eventSupported('touchstart')) { $status.html("sorry your device not supported"); } }, 1000); 

Testing support for 'deviceOrientation' implemented via setTimeout , and not similar to eventSupported , since there are devices (for example, HTC One V) that support 'deviceOrientation' nominally, but the event itself does not occur. In fact, we are waiting for the occurrence of an event (which should definitely occur) for some period of time, and if it does not occur, we conclude that the event is not supported. Such a check is actually a hack.

For some phones (for example, HTC with Windows Phone) the standard browser (mobile IE) does not support the 'touchstart' event, but supports the higher 'click' event. We refused to support such devices, since the response time when using the 'click' event (300 ms) is much longer than that of the 'touchstart' and it is not possible to provide the necessary level of response for monitoring using such devices.

By the way, users of some models of Macbook Pro with HDD can use their laptop in this mode, as it has a gyroscope.

For users of devices running Android 4.0 and above, there is a small bonus - the controller's reverse response in the form of vibration, if faced with a stone (vibration 100 ms) or pick up a coin (vibration 10 ms). To do this, use the Vibration API (requires an updated standard browser, mobile Chrome or Firefox). Read more about the Vibration API here .

When controlling the tilt of the device, the user may not touch the screen for a long time, the device locks, the screen goes blank and the browser stops sending data from the gyroscope. To prevent this behavior, a hack was used that represents an audio loop: a 10-second silent track that plays cyclically and starts when you press the buttons: start, restart, pause.

 <audio id="audioloop" src="../sounds/loop.mp3" onended="this.play();" autobuffer></audio> 

 $('#btnSphere').on('touchstart',function () { socket.emit('click', {'click':'pause'}); $('#audioloop').trigger('play'); }); 

At the same time, on Android devices, the audio loop can be 1 second, and on iOS devices, a longer track is required. In iOS, Safari does not lose track indefinitely, the number of cycles is about 100, so the track length of 10 seconds was chosen.

Webcam control


Webcam control is based on the getUserMedia() method.
We looked at a few examples of control using a webcam. One option is to press the virtual keys, as in this example .



We refused it because it was not accurate enough.

Another option is to use the head inclination angle and the headtrackr.js library . It turned out to be more interesting and helped stretch the neck and relieve tension, but the angle was not always determined correctly. As a result, the head position and its movement relative to the middle of the screen (also with the help of headtrackr.js) are used to control using the webcam.

This method of control is an order of magnitude more complicated than a keyboard or mobile, so the speed of the game in the webcam control mode is reduced.

Back-end


The game server runs on node.js. The express, socket.io, mongoose, node-dogecoin, and hookshot modules are used.

Everything is rather trivial: socket.io provides transport, express is responsible for routes and statics, and mongoose saves clients to the database. Hookshot is used to quickly deploy changes to a VPS.

 app.use('/webhook', hookshot('refs/heads/master', 'git pull')); 

The most interesting in the back-end is the interaction with the dogecoin daemon deployed on the same server. This is a full-fledged dogecoin wallet, the interaction with which is carried out using the node-dogecoin module in the following way:

 dogecoin.exec('getbalance', function(err, balance) { console.log(err, balance); }); 

In addition, the server checks the client for fraud. Here the number of coins collected by the client is checked and compared with the maximum number of coins that can be collected during this session.
Code
 var checkCoins = function (timeStart, timeEnd, coinsCollect) { var time = (timeEnd - timeStart)/1000, maxCoins = calcMaxCoins(time); // if client recieve more coins than it may return coinsCollect <= maxCoins; }; var calcMaxCoins = function (time) { var speedStart = 1/60, acceleration = 1/2500, maxPath = 0, maxCoins = 0, t = 0.25, // coins position in the tube dt = 0.004, // coins position offset n = 10; // number of coins in a row maxPath = (speedStart + acceleration * Math.sqrt(time * 60)) * time; maxCoins = Math.floor(maxPath / (t + dt * (n - 1)) * n)/10; console.log('time:' + time, 'maxCoins:' + maxCoins, 'maxPath:' + maxPath); return maxCoins; }; 

IP, c UID (cookie) IP.
Code
 var checkClient = function (clients, currentClient) { console.log("Handle clients from Array[" + clients.length + "]") var IPpaymentsCounter = 0, UIDpaymentsCounter = 0, IPtimeCounter = 60 * 1000, checkup = null; clients.forEach(function(client, i) { if (client.clientIp === currentClient.clientIp && client.paymentRequest) { IPpaymentsCounter += client.paymentRequest; if (currentClient.timeEnd && currentClient.cientId !== client.cientId) { Math.min(IPtimeCounter, currentClient.timeEnd - client.timeEnd); } } if (client.cookieUID === currentClient.cookieUID && client.paymentRequest) { UIDpaymentsCounter += client.paymentRequest; } // console.log("handle client #" + i); }); console.log('IPtimeCounter', IPtimeCounter); if (currentClient.checkup === false || currentClient.maxCoinsCheck === false || IPpaymentsCounter > 1000 || UIDpaymentsCounter > 100 || IPtimeCounter < 20 * 1000) { checkup = false; } else { checkup = true; } return checkup; }; 

, .

Conclusion


, . , .

GitHub, .

: github , ,

:

Source: https://habr.com/ru/post/230323/


All Articles