├── .gitignore ├── .travis.yml ├── LICENSE ├── bots ├── chicken.js ├── drift.js ├── nitro.js ├── noop.js ├── pig.js ├── third.js └── undermind.js ├── config.js ├── engine ├── bot.js ├── defaultConfig.js ├── index.js ├── levels.js ├── powerup.js ├── protoBot.js ├── shell.js ├── tests │ └── bot.spec.js └── vutils.js ├── example ├── assets │ ├── brain.svg │ ├── score.svg │ ├── tank.svg │ └── tank2.png ├── client.js ├── index.html ├── jquery.js ├── lodash.js ├── map.js ├── sender.js ├── socket.io.js ├── style.css ├── tabs.js └── tank.js ├── gulpfile.js ├── package.json ├── readme.md ├── server.js └── server ├── room.js └── roomManager.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | npm-debug.log 3 | node_modules 4 | *.sublime-project 5 | *.sublime-workspace 6 | /build 7 | /bots/hidden -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.12" 4 | script: 5 | - gulp test -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, 2GIS (http://2gis.ru) 2 | Copyright (c) 2015, Dmitry Kuznetsov 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 16 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 17 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 19 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 20 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 21 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 22 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 23 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 24 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /bots/chicken.js: -------------------------------------------------------------------------------- 1 | module.exports = function() { 2 | this.escape(); 3 | }; -------------------------------------------------------------------------------- /bots/drift.js: -------------------------------------------------------------------------------- 1 | module.exports = function() { 2 | var moves = ['down', 'left', 'up', 'right']; 3 | var quadrant = 1; 4 | if (this.x > this.map.size.x / 2 && this.y <= this.map.size.y / 2) quadrant = 2; 5 | if (this.x > this.map.size.x / 2 && this.y > this.map.size.y / 2) quadrant = 3; 6 | if (this.x <= this.map.size.x / 2 && this.y > this.map.size.y / 2) quadrant = 4; 7 | 8 | this[moves[quadrant - 1]](); 9 | this.nitro(); 10 | }; -------------------------------------------------------------------------------- /bots/nitro.js: -------------------------------------------------------------------------------- 1 | module.exports = function() { 2 | this.down(); 3 | if (this.y > 20) this.nitro(); 4 | }; -------------------------------------------------------------------------------- /bots/noop.js: -------------------------------------------------------------------------------- 1 | module.exports = function() { 2 | // do nothing 3 | }; -------------------------------------------------------------------------------- /bots/pig.js: -------------------------------------------------------------------------------- 1 | module.exports = function() { 2 | var moves = ['left', 'up', 'right', 'down']; 3 | this.movNum = this.movNum || 0; 4 | 5 | if (!this.count) { 6 | var method = moves[this.movNum++ % 4]; 7 | this.method = method; 8 | this.count = 10; 9 | } 10 | this[this.method](); 11 | 12 | this.count--; 13 | }; -------------------------------------------------------------------------------- /bots/third.js: -------------------------------------------------------------------------------- 1 | module.exports = function() { 2 | var moves = ['left', 'up', 'right', 'down']; 3 | var method = moves[Math.round(Math.random() * 4)]; 4 | 5 | method = method || 'left'; 6 | this[method](); 7 | this.fire(); 8 | }; -------------------------------------------------------------------------------- /bots/undermind.js: -------------------------------------------------------------------------------- 1 | module.exports=function(){var a=this._,b="left",c=a.min(this.frame.players,function(a){if(!(a.x==this.x&&a.y==this.y||a.ticksToRespawn)){var b=Math.sqrt((a.x-this.x)*(a.x-this.x)+(a.y-this.y)*(a.y-this.y));return Math.min(Math.abs(a.x-this.x),Math.abs(a.y-this.y))+.1*b}},this),d=!0;Math.abs(c.x-this.x)<2?b=c.y>this.y?"down":"up":Math.abs(c.y-this.y)<2?b=c.x>this.x?"right":"left":(d=!1,Math.abs(c.x-this.x)>Math.abs(c.y-this.y)?(c.y>this.y&&(b="down"),c.ythis.x&&(b="right"),c.xb&&(a.x>=this.x-1&&a.x<=this.x+this.width+1||a.y>=this.y-1&&a.y<=this.y+this.height+1)}},this);e&&(e.x>=this.x-1&&e.x<=this.x+this.width/2?b="right":e.x>this.x+this.width/2&&e.x<=this.x+this.width+1?b="left":e.y>=this.y-1&&e.y<=this.y+this.height/2?b="down":e.y>this.y+this.height/2&&e.y<=this.y+this.height+1&&(b="up")),this[b](),d&&this.fire()}; -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | tankSize: 5, 3 | port: 3009, 4 | tick: 100, 5 | fire: 1000, 6 | respawn: 10, // Количество тиков от смерти до появления в новом месте 7 | immortal: 25, // Максимальное время бессмертия после респавна 8 | 9 | aiTimeout: 10, // Максимально допустимое время на 1 тик для бота 10 | maxPlayers: 10, // Максимальное число игроков на арене 11 | saveBestPlayersNum: 0 // Число лучших игроков которые не вылетают при рестарте арены 12 | }; -------------------------------------------------------------------------------- /engine/bot.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | 3 | function getType(expAngle, realAngle) { 4 | function scalar(a, b) { 5 | return a[0] * b[0] + a[1] * b[1]; 6 | } 7 | 8 | function vector(a, b) { 9 | return a[1] * b[0] - b[1] * a[0]; // Векторное произведение для _левой системы координат 10 | } 11 | 12 | var s = scalar(expAngle, realAngle); 13 | var v = vector(expAngle, realAngle); 14 | 15 | if (s) { 16 | if (s == 1) return 'front'; 17 | if (s == -1) return 'rear'; 18 | } else if (v) { 19 | if (v == 1) return 'right'; 20 | if (v == -1) return 'left'; 21 | } 22 | 23 | }; 24 | 25 | exports.eachSegment = function(callback, thisArg) { 26 | var bot = this || {}; 27 | thisArg = thisArg || this; 28 | 29 | type = getType([0, -1], bot.angle); 30 | callback.call(thisArg, [bot.x, bot.y], [bot.x + bot.width, bot.y], type); 31 | 32 | type = getType([1, 0], bot.angle); 33 | callback.call(thisArg, [bot.x + bot.width, bot.y], [bot.x + bot.width, bot.y + bot.height], type); 34 | 35 | type = getType([0, 1], bot.angle); 36 | callback.call(thisArg, [bot.x + bot.width, bot.y + bot.height], [bot.x, bot.y + bot.height], type); 37 | 38 | type = getType([-1, 0], bot.angle); 39 | callback.call(thisArg, [bot.x, bot.y + bot.height], [bot.x, bot.y], type); 40 | }; -------------------------------------------------------------------------------- /engine/defaultConfig.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | tick: 200, 3 | fire: 1000, 4 | respawn: 10, // Количество тиков от смерти до появления в новом месте 5 | immortal: 25 // Максимальное количество тиков бессмертия после респавна 6 | } -------------------------------------------------------------------------------- /engine/index.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | // direction = ['up', 'down', 'left', 'right'] - направление в котором смотрит танк 3 | 4 | var isServer = typeof window == 'undefined'; 5 | var vm = require('vm'); 6 | 7 | var _ = require('lodash'); 8 | var inherits = require('util').inherits; 9 | var EventEmitter = require('events').EventEmitter; 10 | var lineIntersect = require("line-intersect"); 11 | var botUtils = require('./bot'); 12 | 13 | var vutils = require('./vutils'); 14 | 15 | var ProtoBot = require('./protoBot'); 16 | 17 | inherits(Engine, EventEmitter); 18 | function Engine(config) { 19 | EventEmitter.call(this); 20 | 21 | this.config = config || {}; 22 | this.history = {}; 23 | 24 | _.defaults(this.config, require('./defaultConfig')); 25 | 26 | this.bots = []; // Массив дескрипторов загруженных на карту ботов, написанных игроками 27 | this.update = config.update || _.noop; 28 | this.history.bots = {}; 29 | } 30 | 31 | var powerupCode = require('./powerup'); 32 | 33 | // Инициализация игры: загрузка ботов, конфига 34 | Engine.prototype.level = function(levelName) { 35 | this._levelName = levelName; 36 | var levels = require('./levels'); 37 | var level = _.isObject(levelName) ? levelName : levels[levelName]; 38 | 39 | if (!level) return; 40 | 41 | this.bots = []; 42 | this.shells = []; 43 | this._gameTicks = 0; 44 | 45 | if (level.map) { 46 | this.map = _.cloneDeep(level.map); 47 | } 48 | if (!this.map.spawnPoints) { // Дефолтные точки спавна - по углам карты 49 | this.map.spawnPoints = [ 50 | {x: 0, y: 0}, 51 | {x: this.map.size.x - this.config.tankSize, y: 0}, 52 | {x: this.map.size.x - this.config.tankSize, y: this.map.size.y - this.config.tankSize}, 53 | {x: 0, y: this.map.size.y - this.config.tankSize} 54 | ]; 55 | } 56 | 57 | _.each(level.bots, function(bot) { 58 | this.spawn(_.clone(bot)); 59 | }, this); 60 | 61 | // Спавним поверапы 62 | this.map.powerups = _.map(level.powerups, function(powerupCfg) { 63 | var powerup = _.cloneDeep(powerupCfg); 64 | powerup = _.defaults(powerup, powerupCode.config[powerupCfg.type] || {}); 65 | 66 | if (powerup.leading) powerup.appearIn = 0; // Ставим перк на карту сразу 67 | else powerup.appearIn = powerup.timeout; 68 | 69 | return powerup; 70 | }, this); 71 | 72 | this.success = level.success || _.noop; 73 | this.fail = level.fail || _.noop; 74 | this.stage = 1; 75 | }; 76 | 77 | Engine.prototype.restart = function() { 78 | this.emit('levelRestart'); 79 | this.level(this._levelName); 80 | }; 81 | 82 | // Запуск игры 83 | Engine.prototype.run = function(config) { 84 | this.stage = 1; 85 | this._gameTicks = 0; 86 | 87 | function tick() { 88 | // Расчет перемещений всех объектов на карте 89 | var lastFrame = _.last(this.history.frames); 90 | if (this.stage < 10) { 91 | if (this.success.call(this, lastFrame, this.map) || this._timeout <= 0) { 92 | this.stage = 10; 93 | this.emit('levelComplete'); 94 | } else if (this.fail.call(this, lastFrame, this.map)) { 95 | this.stage = 10; 96 | this.emit('levelFail'); 97 | } 98 | } 99 | if (this.stage < 10) this.ai(); 100 | this.push(); 101 | this.kinetic(); 102 | 103 | this._gameTicks++; 104 | if (this._timeout) this._timeout--; // Условие выхода из игры по времени 105 | 106 | this.tickTimeout = setTimeout(tick.bind(this), this.config.tick); 107 | } 108 | 109 | this.push(); 110 | tick.call(this); 111 | }; 112 | 113 | Engine.prototype.stop = function() { 114 | clearTimeout(this.tickTimeout); 115 | }; 116 | 117 | // Загрузка игроков на карту 118 | Engine.prototype.spawn = function(params) { 119 | var ai = params.ai || 'noop'; 120 | var botAI = require('../bots/' + ai + '.js'); 121 | 122 | var botParams = _.extend({}, params, { 123 | ai: botAI, 124 | kill: params.kill || 0, 125 | death: params.death || 0, 126 | direction: this.map.spawnPoints[params.spawn || 0].direction || 'up', 127 | lives: params.lives || Infinity 128 | }); 129 | 130 | this.add(botParams); 131 | }; 132 | 133 | // Убивает всех в прямоугольнике 134 | Engine.prototype._telefrag = function(bot) { 135 | // Убиваем всех кто оказался в зоне спавна в момент спавна 136 | return _.each(this.bots, function(obot) { 137 | if (bot != obot && 138 | Math.abs((obot.x + obot.width / 2) - (bot.x + bot.width / 2)) < (obot.width + bot.width) / 2 && 139 | Math.abs((obot.y + obot.height / 2) - (bot.y + bot.height / 2)) < (obot.height + bot.height) / 2) { 140 | obot.health = 0; 141 | obot.ticksToRespawn = this.config.respawn; 142 | obot.death++; 143 | bot.lives--; 144 | bot.kill++; 145 | } 146 | }, this); 147 | }; 148 | 149 | // Ищет подходящее для спавна случайное место: либо заданный спавн, либо случайный из свободных, либо просто случайный из всех 150 | Engine.prototype._chooseSpawnPoint = function(bot) { 151 | var spawnPointNum; 152 | var spawn; 153 | 154 | if (_.isNumber(bot.spawn)) { 155 | spawnPointNum = bot.spawn; 156 | spawn = this.map.spawnPoints[spawnPointNum]; 157 | } else { 158 | var freePoints = _.filter(this.map.spawnPoints, function(point) { 159 | return !_.any(this.bots, function(bot) { 160 | return point.x >= bot.x && point.x <= bot.x + bot.width && point.y >= bot.y && point.y <= bot.y + bot.height; 161 | }, this); 162 | }, this); 163 | 164 | if (freePoints.length) { 165 | spawnPointNum = _.random(0, freePoints.length - 1); 166 | spawn = freePoints[spawnPointNum]; 167 | } else { 168 | spawnPointNum = _.random(0, this.map.spawnPoints.length - 1); 169 | spawn = this.map.spawnPoints[spawnPointNum]; 170 | } 171 | } 172 | 173 | bot.x = spawn.x; 174 | bot.y = spawn.y; 175 | 176 | this._telefrag(bot); 177 | 178 | return; 179 | }; 180 | 181 | // Пересоздаёт убитого бота, по сути просто меняет его позицию 182 | Engine.prototype.respawn = function(bot) { 183 | if (bot.lives <= 0) return; 184 | 185 | bot.ticksToRespawn--; 186 | 187 | if (bot.ticksToRespawn) return; // Время до рождения 188 | 189 | this._chooseSpawnPoint(bot); 190 | bot.health = 100; 191 | bot.armed = 30; 192 | bot.stamina = 30; 193 | bot.immortal = this.config.immortal; // Тиков до отключения бессмертия 194 | bot.powerups = {}; 195 | }; 196 | 197 | // Добавление бота в игру 198 | Engine.prototype.add = function(params) { 199 | var direction = params.direction || 'up'; 200 | 201 | var bot = { 202 | id: params.id || Math.round(Math.random() * Number.MAX_SAFE_INTEGER / 10), 203 | ai: params.ai, 204 | index: this.bots.length, 205 | name: params.name, 206 | angle: vutils.vector(direction), 207 | vector: vutils.vector(direction), 208 | direction: direction, 209 | width: 5, 210 | height: 5, 211 | kill: params.kill || 0, 212 | death: params.death || 0, 213 | eachSegment: botUtils.eachSegment, 214 | health: 100, 215 | armed: 30, 216 | stamina: 60, 217 | powerups: {}, 218 | immortal: _.isNumber(params.immortal) ? params.immortal : this.config.immortal, 219 | spawn: params.spawn, 220 | lives: params.lives || Infinity 221 | }; 222 | 223 | this._chooseSpawnPoint(bot); 224 | bot.instance = new ProtoBot({ 225 | id: bot.id, 226 | x: bot.x, 227 | y: bot.y, 228 | width: bot.width, 229 | height: bot.height, 230 | name: params.name, 231 | cp: { 232 | want: this.want.bind(this) 233 | } 234 | }); 235 | 236 | if (isServer) { 237 | var str = _.isFunction(bot.ai) ? bot.ai.toString().match(/function[^{]+\{([\s\S]*)\}$/)[1] : bot.ai; // Выдираем тело функции в строку 238 | bot.instance = vm.createContext(bot.instance); 239 | bot.ai = new vm.Script(str); 240 | } 241 | 242 | this.bots.push(bot); 243 | }; 244 | 245 | // Удалить бота из игры 246 | Engine.prototype.remove = function(bot) { 247 | var name = bot.name || bot; 248 | var length = this.bots.length; 249 | 250 | _.remove(this.bots, function(bot) { 251 | return bot.name == name; 252 | }, this); 253 | 254 | return length - this.bots.length - 1; // 0 если был удалён 1 бот 255 | }; 256 | 257 | // Обсчет всей кинетики игры: пересчёт позиций всех объектов 258 | Engine.prototype.kinetic = function() { 259 | this.shellsPositions(); 260 | this.playersPositions(); 261 | }; 262 | 263 | // Обновление позиции всех снарядов. Направление меняться не может 264 | Engine.prototype.shellsPositions = function() { 265 | // Удаляем старые только после того, как удалённые улетели на клиента (сохранились в gameHistory) 266 | this.shells = _.reject(this.shells, function(shell) { 267 | return shell.bursted; 268 | }); 269 | 270 | _.each(this.shells, function(shell, index) { 271 | var x = shell.x + shell.vector[0] * 18; // Новые координаты снаряда 272 | var y = shell.y + shell.vector[1] * 18; 273 | 274 | var wantX = x, wantY = y; 275 | 276 | // Валидация новых координат снаряда 277 | if (x <= 0 || y <= 0 || x >= this.map.size.x || y >= this.map.size.y) { 278 | if (x < 0) x = 0; 279 | if (y < 0) y = 0; 280 | 281 | if (x > this.map.size.x) x = this.map.size.x; 282 | if (y > this.map.size.y) y = this.map.size.y; 283 | 284 | shell.bursted = true; 285 | } else { 286 | // Столкновение снаряда с танком 287 | _.each(this.bots, function(bot) { 288 | if (bot.ticksToRespawn || shell.parent == bot) return; 289 | 290 | bot.eachSegment(function(b0, b1, type) { 291 | if (bot.ticksToRespawn) return; // Уже учли пересечение 292 | 293 | var intersect = lineIntersect.checkIntersection( 294 | shell.x, shell.y, x, y, 295 | b0[0], b0[1], b1[0], b1[1] 296 | ); 297 | 298 | if (intersect.point) { 299 | if (!bot.immortal) { 300 | bot.health -= shell.strength; 301 | if (bot.health <= 0) { 302 | bot.ticksToRespawn = this.config.respawn; 303 | bot.death++; 304 | bot.lives--; 305 | shell.parent.kill++; 306 | this.emit('score', this.getScore()); 307 | } 308 | } 309 | shell.bursted = true; 310 | 311 | // останавливаем пулю в точке пересечения 312 | x = intersect.point.x; 313 | y = intersect.point.y; 314 | } 315 | }, this); 316 | }, this); 317 | } 318 | 319 | // считаем насколько мы скорректировали пулю/ 320 | // а точнее насколько меньший путь проделала пуля 321 | let kx = (x - shell.x)/(wantX - shell.x); 322 | let ky = (y - shell.y)/(wantY - shell.y); 323 | shell.k = isFinite(kx) ? kx : ky; 324 | 325 | shell.x = x; 326 | shell.y = y; 327 | shell.strength = shell.strength * .95; // Уменьшение силы снаряда с расстоянием 328 | }, this); 329 | 330 | this.bots = _.each(this.bots, function(bot) { 331 | if (bot.ticksToRespawn) { 332 | this.respawn(bot); 333 | } 334 | }, this); 335 | }; 336 | 337 | // Обновление позиции всех танков 338 | Engine.prototype.playersPositions = function() { 339 | const inertion = 1; // Инерция танка, то есть неспособность менять направление движения 340 | 341 | this.bots.forEach((bot) => { 342 | if (bot.immortal) bot.immortal--; 343 | if (bot.ticksToRespawn) return; 344 | 345 | // Тяга 346 | var force = bot.nitro ? 2 : 0; 347 | bot.gear += force; 348 | var traction = [bot.angle[0] * bot.gear, bot.angle[1] * bot.gear]; 349 | bot.vector[0] = (inertion * bot.vector[0] + traction[0]) / (inertion + 1); 350 | bot.vector[1] = (inertion * bot.vector[1] + traction[1]) / (inertion + 1); 351 | if (Math.abs(bot.vector[0] - traction[0]) < .1) bot.vector[0] = traction[0]; 352 | if (Math.abs(bot.vector[1] - traction[1]) < .1) bot.vector[1] = traction[1]; 353 | 354 | var x = bot.x + bot.vector[0]; 355 | var y = bot.y + bot.vector[1]; 356 | 357 | if (x < 0) x = 0; 358 | if (y < 0) y = 0; 359 | if (x > this.map.size.x - bot.width) { 360 | x = this.map.size.x - bot.width; 361 | bot.vector[0] = 0; 362 | } 363 | if (y > this.map.size.y - bot.height) { 364 | y = this.map.size.y - bot.height; 365 | bot.vector[1] = 0; 366 | } 367 | 368 | // Пересечения ботов между собой (наезд друг на друга) 369 | var boom = _.any(this.bots, function(obot) { 370 | return obot != bot && 371 | !obot.ticksToRespawn && 372 | Math.abs((obot.x + obot.width / 2) - (x + bot.width / 2)) < (obot.width + bot.width) / 2 && 373 | Math.abs((obot.y + obot.height / 2) - (y + bot.height / 2)) < (obot.height + bot.height) / 2; 374 | }); 375 | 376 | if (boom) { 377 | bot.gear = 0; 378 | bot.vector = [0, 0]; // @TODO правильно обнулять // @TODO запилить физику 379 | } else { 380 | bot.x = bot.instance.x = x; 381 | bot.y = bot.instance.y = y; 382 | } 383 | 384 | // Захват перков 385 | this.powerupsStatus(bot); 386 | 387 | bot.armed++; 388 | if (bot.armed > 30) bot.armed = 30; 389 | 390 | // Пока зажата кнопка ускорения, стамина не восстанавливается + ещё 10 тиков 391 | if (bot.nitroPressed > 0) { 392 | bot.nitroPressed--; 393 | } else { 394 | bot.stamina++; 395 | if (bot.stamina > 60) bot.stamina = 60; 396 | } 397 | 398 | bot.instance.armed = bot.armed; 399 | bot.instance.immortal = bot.immortal; 400 | bot.instance.stamina = bot.stamina; 401 | }); 402 | }; 403 | 404 | Engine.prototype.powerupsStatus = powerupCode.status; 405 | 406 | Engine.prototype.timeout = function(ms) { 407 | this._timeout = ms; 408 | }; 409 | 410 | // Выполнение ИИ функций ботов 411 | Engine.prototype.ai = function() { 412 | var frame = _.cloneDeep(_.last(this.history.frames)); 413 | 414 | _.each(this.bots, function(bot) { 415 | if (bot.ticksToRespawn) return; 416 | 417 | bot.gear = bot.gear || 0; 418 | if (bot.gear > 0) bot.gear--; // Сбрасываем движение 419 | bot.nitro = false; 420 | 421 | bot.instance.frame = _.cloneDeep(frame); 422 | bot.instance.frame.players = _.reject(bot.instance.frame.players, function(player) { 423 | return player.ticksToRespawn || player.id == bot.id; 424 | }, this); 425 | bot.instance.enemy = _.find(bot.instance.frame.players, function(obot) { 426 | return obot.id != bot.id; 427 | }); 428 | bot.instance._ = _; 429 | bot.instance.map = _.cloneDeep(this.map); 430 | bot.instance.health = bot.health; 431 | bot.instance.powerups = _.cloneDeep(bot.powerups); 432 | 433 | try { 434 | // На сервере исполняем ai в виртуальной машине 435 | if (isServer) { 436 | bot.ai.runInContext(bot.instance, { 437 | filename: bot.name + '.bot', 438 | timeout: this.config.aiTimeout 439 | }); 440 | } else { 441 | bot.ai.call(bot.instance); 442 | } 443 | } catch (e) { 444 | console.log('ai[%s] call: %s', bot.name, e.stack); 445 | } 446 | }, this); 447 | }; 448 | 449 | // На лету подменяет ai 450 | Engine.prototype.replaceAI = function(name, str) { 451 | var bot = _.find(this.bots, function(bot) { 452 | return bot.name == name; 453 | }); 454 | 455 | if (bot) { 456 | str = 'var tank = this, map = this.frame;' + str; 457 | var oldAI = bot.ai; 458 | try { 459 | if (isServer) { 460 | bot.ai = new vm.Script(str); 461 | } else { 462 | bot.ai = new Function(str); 463 | } 464 | } catch (e) { 465 | console.log('Error! Bot ' + name + ' ai was not replaced'); 466 | bot.ai = oldAI; 467 | } 468 | } 469 | }; 470 | 471 | // Запоминает новый кадр сражения в массив this.history.frames 472 | Engine.prototype.push = function() { 473 | this.history.frames = this.history.frames || []; 474 | 475 | var historyKeys = ['id', 'name', 'x', 'y', 'kill', 'death', 'direction', 'health', 'ticksToRespawn', 'immortal']; 476 | 477 | // Делаем копии игроков и снарядов с выбранными полями для сохранения в историю 478 | var players = _.map(this.bots, function(bot) { 479 | var botCopy = _.pick(bot, historyKeys); 480 | botCopy.powerups = _.cloneDeep(bot.powerups); 481 | 482 | return botCopy; 483 | }); 484 | players = _.compact(players); 485 | 486 | var shells = _.reduce(this.shells, function(result, shell) { 487 | var pshell = _.pick(shell, ['id', 'x', 'y', 'k', 'bursted']); 488 | 489 | pshell.shooterId = shell.parent.id; 490 | pshell.vector = _.clone(shell.vector); // clone vector 491 | 492 | result.push(pshell); 493 | 494 | return result; 495 | }, []); 496 | 497 | var powerups = _.cloneDeep(this.map.powerups); 498 | 499 | // Сохраняем в историю (в кадр) все данные чтоб потом их можно было передать клиенту 500 | var frame = { 501 | players: players, 502 | shells: shells, 503 | powerups: powerups, 504 | time: Date.now() 505 | }; 506 | 507 | this.history.frames.push(frame); 508 | this.update(this.get({ 509 | since: frame.time - 1 // вернёт только последний фрейм 510 | })); 511 | this.emit('frame', frame); 512 | }; 513 | 514 | // Реквест бота на выполнение какого-то действия, которое может быть и не доступно (например второй выстрел сразу после первого) 515 | Engine.prototype.want = function(instance, action, params) { 516 | var bot = _.find(this.bots, function(bot) { 517 | return instance.id === bot.id; 518 | }); 519 | 520 | if (!bot) throw new Error('Bot with id "' + instance.id + '" not found'); 521 | 522 | if (action == 'fire') { 523 | bot.immortal = 0; // Выключаем бессмертие после первой попытки выстрелить 524 | 525 | if (bot.armed < 10) return; 526 | 527 | bot.armed -= 10; 528 | 529 | this.shells = this.shells || []; 530 | this.shells.push({ 531 | id: Math.round(Math.random() * Number.MAX_SAFE_INTEGER / 10), 532 | x: bot.x + bot.width * 0.5 + bot.angle[0] * bot.width * 0.5, 533 | y: bot.y + bot.height * 0.5 + bot.angle[1] * bot.height * 0.5, 534 | vector: bot.angle, 535 | parent: bot, 536 | strength: 40 537 | }); 538 | 539 | // Отдача 540 | bot.vector[0] -= bot.angle[0] * 2; 541 | bot.vector[1] -= bot.angle[1] * 2; 542 | } 543 | 544 | if (action == 'move') { 545 | bot.angle = params.vector; 546 | bot.direction = instance.direction; 547 | bot.gear = bot.gear || 1; 548 | } 549 | 550 | if (action == 'nitro') { 551 | if (bot.stamina > 0) { 552 | bot.nitro = true; 553 | bot.stamina -= 10; 554 | } 555 | bot.nitroPressed = 10; 556 | } 557 | 558 | this.lastAction = action; 559 | }; 560 | 561 | Engine.prototype.getScore = function() { 562 | var score = _.map(this.bots, function(bot) { 563 | return _.pick(bot, 'id', 'name', 'kill', 'death'); 564 | }, this); 565 | 566 | return score; 567 | }; 568 | 569 | // Возвращает состояния игры и последних кадров сражения 570 | Engine.prototype.get = function(params) { 571 | if (!this.map) return; 572 | 573 | var frames = _.filter(this.history.frames, function(frame) { 574 | return frame.time > params.since; 575 | }); 576 | 577 | return { 578 | map: { 579 | size: this.map.size 580 | }, 581 | frames: frames 582 | }; 583 | }; 584 | 585 | module.exports = Engine; 586 | -------------------------------------------------------------------------------- /engine/levels.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'arena': { 3 | map: { 4 | size: {x: 160, y: 160}, 5 | spawnPoints: [ 6 | {x: 10, y: 10}, 7 | {x: 20, y: 20}, 8 | {x: 30, y: 30}, 9 | {x: 40, y: 40}, 10 | {x: 50, y: 50}, 11 | {x: 60, y: 60}, 12 | {x: 70, y: 70}, 13 | {x: 90, y: 90}, 14 | {x: 100, y: 100}, 15 | {x: 110, y: 110}, 16 | {x: 120, y: 120}, 17 | {x: 130, y: 130}, 18 | {x: 140, y: 140}, 19 | {x: 150, y: 150}, 20 | ] 21 | }, 22 | bots: [{ 23 | name: 'some', 24 | ai: 'chicken' 25 | }, { 26 | name: 'undermind', 27 | ai: 'undermind' 28 | }, { 29 | name: 'undermind2', 30 | ai: 'undermind' 31 | }, { 32 | name: 'Грёбаный капец!', 33 | ai: 'undermind' 34 | }], 35 | powerups: [{ 36 | type: '2gisDamage', 37 | leading: true, 38 | x: 77.5, 39 | y: 77.5 40 | }] 41 | }, 42 | 43 | // Доехать до края 44 | '1': { 45 | map: { 46 | size: {x: 120, y: 100}, 47 | spawnPoints: [{ 48 | x: 57.5, 49 | y: 65, 50 | direction: 'up' 51 | }, { 52 | x: 57.5, 53 | y: 15, 54 | direction: 'right' 55 | }] 56 | }, 57 | bots: [{ 58 | name: 'Твой танк', 59 | spawn: 0 60 | }], 61 | success: function(frame, map) { 62 | return frame && (frame.players[0].x < 1 || frame.players[0].y < 1 || 63 | frame.players[0].x > map.size.x - 6 || frame.players[0].y > map.size.y - 6); 64 | }, 65 | fail: function(frame, map) { 66 | return this._gameTicks > 200; 67 | } 68 | }, 69 | 70 | // Стрельнуть перед собой 71 | '2': { 72 | map: { 73 | size: {x: 120, y: 100}, 74 | spawnPoints: [{ 75 | x: 57.5, 76 | y: 65, 77 | direction: 'up' 78 | }, { 79 | x: 57.5, 80 | y: 15, 81 | direction: 'right' 82 | }] 83 | }, 84 | bots: [{ 85 | name: 'Твой танк', 86 | spawn: 0 87 | }, { 88 | name: 'cow', 89 | spawn: 1, 90 | immortal: 0, 91 | lives: 1 92 | }], 93 | success: function(frame) { 94 | return frame && frame.players[1].health <= 0; 95 | } 96 | }, 97 | 98 | // Убить двух неподвижных 99 | '3': { 100 | map: { 101 | size: {x: 120, y: 100}, 102 | spawnPoints: [{ 103 | x: 57.5, 104 | y: 65, 105 | direction: 'up' 106 | }, { 107 | x: 10, 108 | y: 65, 109 | direction: 'right' 110 | }, { 111 | x: 85, 112 | y: 65, 113 | direction: 'left' 114 | }] 115 | }, 116 | bots: [{ 117 | name: 'Твой танк', 118 | spawn: 0 119 | }, { 120 | name: 'cow', 121 | spawn: 1, 122 | immortal: 0, 123 | lives: 1 124 | }, { 125 | name: 'cow2', 126 | spawn: 2, 127 | immortal: 0, 128 | lives: 1 129 | }], 130 | success: function(frame) { 131 | return frame && frame.players[1].health <= 0 && frame.players[2].health <= 0; 132 | } 133 | }, 134 | 135 | // Доехать и убить 136 | '4': { 137 | map: { 138 | size: {x: 120, y: 100}, 139 | spawnPoints: [{ 140 | x: 30, 141 | y: 65, 142 | direction: 'up' 143 | }, { 144 | x: 57.5, 145 | y: 45, 146 | direction: 'right' 147 | }] 148 | }, 149 | bots: [{ 150 | name: 'Твой танк', 151 | spawn: 0 152 | }, { 153 | name: 'cow', 154 | ai: 'noop', 155 | spawn: 1, 156 | immortal: 0, 157 | lives: 1 158 | }], 159 | success: function(frame) { 160 | return frame && frame.players[1].health <= 0; 161 | } 162 | }, 163 | 164 | // Догнать и убить 165 | '5': { 166 | map: { 167 | size: {x: 120, y: 100}, 168 | spawnPoints: [{ 169 | x: 30, 170 | y: 65, 171 | direction: 'up' 172 | }, { 173 | x: 57.5, 174 | y: 45, 175 | direction: 'right' 176 | }] 177 | }, 178 | bots: [{ 179 | name: 'Твой танк', 180 | spawn: 0 181 | }, { 182 | name: 'chicken', 183 | ai: 'chicken', 184 | spawn: 1, 185 | immortal: 0, 186 | lives: 1 187 | }], 188 | success: function(frame) { 189 | return frame && frame.players[1].health <= 0; 190 | } 191 | }, 192 | 193 | // Обогнать и взять 2gis damage 194 | '6': { 195 | map: { 196 | size: {x: 120, y: 100}, 197 | spawnPoints: [{ 198 | x: 57.5, 199 | y: 85, 200 | direction: 'up' 201 | }, { 202 | x: 57.5, 203 | y: 10, 204 | direction: 'down' 205 | }] 206 | }, 207 | bots: [{ 208 | name: 'Твой танк', 209 | spawn: 0 210 | }, { 211 | name: 'aaaa', 212 | ai: 'nitro', 213 | spawn: 1 214 | }], 215 | powerups: [{ 216 | type: '2gisDamage', 217 | leading: true, 218 | x: 57.5, 219 | y: 47.5 220 | }], 221 | success: function(frame) { 222 | return frame && frame.players[0].powerups['2gisDamage']; 223 | }, 224 | fail: function(frame) { 225 | return this._gameTicks > 50 && frame && frame.players[1].powerups['2gisDamage']; 226 | } 227 | }, 228 | 229 | // Обогнать и взять 2gis damage по кривой 230 | '7': { 231 | map: { 232 | size: {x: 120, y: 100}, 233 | spawnPoints: [{ 234 | x: 10, 235 | y: 85, 236 | direction: 'up' 237 | }, { 238 | x: 85, 239 | y: 10, 240 | direction: 'down' 241 | }] 242 | }, 243 | bots: [{ 244 | name: 'Твой танк', 245 | spawn: 0 246 | }, { 247 | name: 'aaaa', 248 | ai: 'drift', 249 | spawn: 1 250 | }], 251 | powerups: [{ 252 | type: '2gisDamage', 253 | leading: true, 254 | x: 57.5, 255 | y: 47.5 256 | }], 257 | success: function(frame) { 258 | return frame && frame.players[0].powerups['2gisDamage']; 259 | }, 260 | fail: function(frame) { 261 | return this._gameTicks > 150 && frame && frame.players[1].powerups['2gisDamage']; 262 | } 263 | }, 264 | 265 | // Бой против ботов 266 | '8': { 267 | map: { 268 | size: {x: 120, y: 100}, 269 | spawnPoints: [{ 270 | x: 20, 271 | y: 20, 272 | direction: 'up' 273 | }, { 274 | x: 20, 275 | y: 75, 276 | direction: 'right' 277 | }, { 278 | x: 95, 279 | y: 75, 280 | direction: 'right' 281 | }, { 282 | x: 95, 283 | y: 20, 284 | direction: 'right' 285 | }] 286 | }, 287 | bots: [{ 288 | name: 'Твой танк', 289 | spawn: 0 290 | }, { 291 | name: 'undermind', 292 | ai: 'undermind', 293 | spawn: 1 294 | }, { 295 | name: 'undermind2', 296 | ai: 'undermind', 297 | spawn: 2 298 | }, { 299 | name: 'Грёбаный капец!', 300 | ai: 'undermind', 301 | spawn: 3, 302 | immortal: 0 303 | }], 304 | powerups: [{ 305 | type: '2gisDamage', 306 | leading: true, 307 | x: 57.5, 308 | y: 47.5 309 | }] 310 | } 311 | }; -------------------------------------------------------------------------------- /engine/powerup.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var config = require('../config'); 3 | 4 | exports.status = function(bot) { 5 | if (bot.health <= 0) return; 6 | 7 | _.each(bot.powerups, function(value, key) { 8 | if (_.isFinite(bot.powerups[key]) && bot.powerups[key] > 0) { 9 | bot.powerups[key]--; 10 | } 11 | 12 | if (bot.powerups['2gisDamage'] > 0) { 13 | bot.stamina = 60; 14 | bot.armed = 60; 15 | } 16 | }, this); 17 | 18 | _.each(this.map.powerups, function(powerup) { 19 | // Наезд бота на поверап - взятие 20 | if (powerup.appearIn == 0 && // Если поверап есть на карте 21 | Math.abs((powerup.x + powerup.width / 2) - (bot.x + bot.width / 2)) < (powerup.width + bot.width) / 2 && 22 | Math.abs((powerup.y + powerup.height / 2) - (bot.y + bot.height / 2)) < (powerup.height + bot.height) / 2) { 23 | 24 | bot.powerups[powerup.type] = 50; 25 | powerup.appearIn = powerup.timeout; 26 | } else if (_.isFinite(powerup.appearIn) && powerup.appearIn > 0) { 27 | powerup.appearIn--; 28 | } 29 | }, this); 30 | }; 31 | 32 | // Дефолтные настройки поверапов 33 | exports.config = { 34 | '2gisDamage': { 35 | point: [47.5, 57.5], 36 | timeout: 50, 37 | leading: false, 38 | width: config.tankSize, 39 | height: config.tankSize 40 | } 41 | }; -------------------------------------------------------------------------------- /engine/protoBot.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | 3 | var vector = require('../engine/vutils').vector; 4 | 5 | var protoBot = function(params) { 6 | params = params || {}; 7 | 8 | this.id = params.id; 9 | this.x = params.x; 10 | this.y = params.y; 11 | this.width = params.width; 12 | this.height = params.height; 13 | this.name = params.name; 14 | this.cp = params.cp; 15 | }; 16 | 17 | protoBot.prototype = {}; 18 | 19 | // Движение влево, вверх, вправо или вниз 20 | protoBot.prototype.move = function(direction) { 21 | if (!_.contains(['left', 'up', 'right', 'down'], direction)) return; 22 | 23 | this.direction = direction; 24 | 25 | this.cp.want(this, 'move', { 26 | direction: direction, 27 | vector: vector(direction) 28 | }); 29 | }; 30 | 31 | protoBot.prototype.left = function() { 32 | return this.move('left'); 33 | }; 34 | 35 | protoBot.prototype.up = function() { 36 | return this.move('up'); 37 | }; 38 | 39 | protoBot.prototype.right = function() { 40 | return this.move('right'); 41 | }; 42 | 43 | protoBot.prototype.down = function() { 44 | return this.move('down'); 45 | }; 46 | 47 | // Выстрел в точку с координатами x, y 48 | protoBot.prototype.fire = function(direction) { 49 | return this.cp.want(this, 'fire'); 50 | }; 51 | 52 | protoBot.prototype.nitro = function() { 53 | return this.cp.want(this, 'nitro'); 54 | }; 55 | 56 | protoBot.prototype.pursue = function(e) { 57 | var enemy = e || this.enemy; 58 | var deltaX = this.x - enemy.x; 59 | var deltaY = this.y - enemy.y; 60 | 61 | // if (Math.abs(deltaY) < Math.abs(deltaX) || Math.abs(deltaX) < 1) { 62 | if (Math.abs(deltaY) < Math.abs(deltaX) && Math.abs(deltaY) > 2 || Math.abs(deltaX) < 2) { 63 | if (deltaY < 0) return this.move('down'); 64 | else return this.move('up'); 65 | } else { 66 | if (deltaX < 0) return this.move('right'); 67 | else return this.move('left'); 68 | } 69 | }; 70 | 71 | // Отвечает на вопрос: противник на мушке? 72 | protoBot.prototype.locked = function(enemy, eps) { 73 | enemy = enemy || this.enemy; 74 | eps = _.isNumber(eps) ? eps : 1; 75 | 76 | var deltaX = this.x - enemy.x; 77 | var deltaY = this.y - enemy.y; 78 | 79 | return Math.abs(deltaX) < eps || Math.abs(deltaY) < eps; 80 | }; 81 | 82 | // Пытается убежать на максимальную диагональ 83 | protoBot.prototype.escape = function(from) { 84 | this.enemy = from || this.enemy; 85 | from = from || this.enemy; 86 | 87 | // Для учета краевых эффектов вводим эффективное положение бота 88 | var effX = this.x; if (effX < 6) effX = 6; if (effX > this.map.size.x - 11) effX = 11; 89 | var effY = this.y; if (effY < 6) effY = 6; if (effY > this.map.size.y - 11) effY = 11; 90 | var deltaX = effX - from.x; 91 | var deltaY = effY - from.y; 92 | 93 | if (Math.abs(deltaX) < Math.abs(deltaY)) { 94 | // Если ты справа от него и не у левой границы карты 95 | if (deltaX < 0) this.left(); 96 | else this.right(); 97 | } else { 98 | // Если ты снизу от него и не у верхней границы карты 99 | if (deltaY < 0) this.up(); 100 | else this.down(); 101 | } 102 | }; 103 | 104 | module.exports = protoBot; -------------------------------------------------------------------------------- /engine/shell.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | 3 | var slot; 4 | 5 | var Shell = function(x0, y0, xdest, ydest) { 6 | params = params || {}; 7 | 8 | this.x = x0; 9 | this.y = y0; 10 | this.destination = {x: xdest, y: ydest}; 11 | }; 12 | 13 | Shell.prototype = {}; 14 | 15 | Shell.prototype.dispose = function() { 16 | 17 | }; 18 | 19 | module.exports = Shell; -------------------------------------------------------------------------------- /engine/tests/bot.spec.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var botUtils = require('../bot'); 3 | var sinon = require('sinon'); 4 | var assert = require('assert'); 5 | 6 | describe('Приватные методы бота', function() { 7 | describe('eachSegment', function() { 8 | it('Callback called for each segment', function() { 9 | var spy = sinon.spy(); 10 | var fakeBot = { 11 | angle: [0, 1] 12 | }; 13 | 14 | botUtils.eachSegment.call(fakeBot, spy); 15 | 16 | sinon.assert.callCount(spy, 4); 17 | }); 18 | }); 19 | }); -------------------------------------------------------------------------------- /engine/vutils.js: -------------------------------------------------------------------------------- 1 | 2 | exports.vector = function(direction) { 3 | switch (direction) { 4 | case 'left': return [-1, 0]; break; 5 | case 'up': return [0, -1]; break; 6 | case 'right': return [1, 0]; break; 7 | case 'down': return [0, 1]; break; 8 | } 9 | }; -------------------------------------------------------------------------------- /example/assets/brain.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 14 | 18 | 21 | 24 | 27 | 30 | 37 | 40 | 66 | 73 | 80 | 84 | 87 | 90 | 93 | 97 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /example/assets/score.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 10 | 12 | 14 | 16 | 18 | 20 | 22 | 24 | 26 | 28 | 30 | 31 | -------------------------------------------------------------------------------- /example/assets/tank.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 17 | 26 | 55 | 87 | 496 | 497 | 498 | -------------------------------------------------------------------------------- /example/assets/tank2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/2gis/Battlegis/70644c5953631aa40c5febaa12c68d4d3ef91731/example/assets/tank2.png -------------------------------------------------------------------------------- /example/client.js: -------------------------------------------------------------------------------- 1 | window.Battlegis = require('../engine'); -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Client 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |
21 |
22 |
23 |
24 | 25 |
26 | 27 |
1
28 |
29 | 30 |
31 |
32 | 33 |
34 | 35 |
36 |
37 | 38 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /example/map.js: -------------------------------------------------------------------------------- 1 | var ANIM_DURATION = 200; 2 | 3 | var PIXI = require('pixi.js'); 4 | var Tank = require('./tank'); 5 | 6 | $(function() { 7 | var shells = {}; 8 | var tanks = {}; 9 | var i = 0; 10 | var map = $('.map'); 11 | 12 | var stage = new PIXI.Stage(0x000000); 13 | var renderer = PIXI.autoDetectRenderer(400, 300); 14 | 15 | var tankTexture = PIXI.Texture.fromImage("assets/tank2.png"); 16 | 17 | var underlay = new PIXI.Graphics(); 18 | var tanksLayer = new PIXI.DisplayObjectContainer(); 19 | 20 | stage.addChild(underlay); 21 | stage.addChild(tanksLayer); 22 | 23 | map[0].appendChild(renderer.view); 24 | 25 | window.game = new Battlegis({ 26 | update: function(data) { 27 | if (data.map.size) { 28 | var w = data.map.size.x * 5, 29 | h = data.map.size.y * 5; 30 | map.css({ 31 | width: w, 32 | height: h 33 | }); 34 | renderer.resize(w, h); 35 | } 36 | updateMap(data.frames[data.frames.length - 1]); 37 | } 38 | }); 39 | 40 | 41 | function scoreTemplate(data) { 42 | data = _.sortBy(data, function(player) { 43 | return -player.kill * 1000 - player.death; 44 | }); 45 | var rows = _.map(data, function(player) { 46 | return '
' + player.name + '
' + 47 | player.kill + '
' + player.death + '
' 48 | }); 49 | 50 | return '
' + rows.join('') + '
'; 51 | } 52 | 53 | function getTank(bot) { 54 | if (!(bot.id in tanks)) { 55 | var tank = tanks[bot.id] = new Tank(bot.name, tankTexture); 56 | tanksLayer.addChild(tank); 57 | } 58 | return tanks[bot.id]; 59 | } 60 | 61 | function linear(start, stop, k) { 62 | return start + k*(stop - start); 63 | } 64 | 65 | function wantMove(obj, x, y) { 66 | if (!obj.prev) { 67 | obj.position.set(x, y); 68 | } else { 69 | obj.want = obj.want || new PIXI.Point(); 70 | obj.want.set(x, y); 71 | obj.wantTime = Date.now(); 72 | } 73 | 74 | obj.prev = obj.position.clone(); 75 | } 76 | 77 | function move(obj, speedFactor) { 78 | if (!obj.want) { 79 | // skip 80 | return; 81 | } 82 | var now = Date.now(); 83 | 84 | if (speedFactor === void 0) speedFactor = 1; 85 | 86 | var duration = ANIM_DURATION*speedFactor; 87 | 88 | var k = (now - obj.wantTime)/duration; 89 | 90 | if (k > 1) k = 1; 91 | 92 | obj.position.set( 93 | linear(obj.prev.x, obj.want.x, k), 94 | linear(obj.prev.y, obj.want.y, k) 95 | ); 96 | 97 | if (k === 1) { 98 | obj.want = obj.wantTime = null; 99 | } 100 | } 101 | 102 | function getShell(id) { 103 | if (!shells[id]) { 104 | shells[id] = { 105 | id: id, 106 | position: new PIXI.Point() 107 | }; 108 | } 109 | 110 | return shells[id]; 111 | } 112 | 113 | function killShell(shell) { 114 | delete shells[shell.id]; 115 | } 116 | 117 | 118 | function updateMap(frame) { 119 | if (!frame) return; 120 | 121 | var score = []; 122 | 123 | _.each(frame.players, function(fplayer, index) { 124 | var tank = getTank(fplayer); 125 | 126 | wantMove(tank, fplayer.x * 5, fplayer.y * 5); 127 | 128 | tank.rotate(fplayer.direction); 129 | 130 | if (fplayer.ticksToRespawn) { 131 | // @TODO: animation 132 | tank.prev = tank.want = null; 133 | tank.renderable = false; 134 | tanksLayer.removeChild(tank); 135 | } else { 136 | tank.renderable = true; 137 | tanksLayer.addChild(tank); 138 | } 139 | tank.health = fplayer.health; 140 | tank.setImmortal(fplayer.immortal); 141 | 142 | score[index] = fplayer; 143 | }); 144 | 145 | _.each(frame.shells, function(fshell) { 146 | var shell = getShell(fshell.id); 147 | if (!shell.burstedTime) { 148 | shell.k = isFinite(Number(fshell.k)) ? Number(fshell.k) : 1; 149 | 150 | wantMove(shell, fshell.x * 5, fshell.y * 5); 151 | if (fshell.bursted) { 152 | shell.burstedTime = Date.now(); 153 | } 154 | } 155 | 156 | }); 157 | 158 | $('.game__score').html(scoreTemplate(score)); 159 | } 160 | 161 | function animationFrame() { 162 | underlay.clear(); 163 | 164 | // рисуем ботов 165 | 166 | _.each(game.bots, function(bot) { 167 | var tank = getTank(bot); 168 | if (!tank.renderable) return; 169 | 170 | move(tank); 171 | }); 172 | 173 | underlay.beginFill(0xFF0000, 1); 174 | 175 | _.each(shells, function(shell) { 176 | // если пуля не хочет двигаться, значет она мертва :) 177 | if (!shell.want && shell.burstedTime) return killShell(shell); 178 | 179 | move(shell, shell.k); 180 | underlay.drawCircle(shell.position.x, shell.position.y, 3); 181 | }); 182 | 183 | 184 | renderer.render(stage); 185 | 186 | requestAnimationFrame(animationFrame); 187 | } 188 | 189 | var level = 7; 190 | game.level(level); 191 | 192 | game.on('levelComplete', function(e) { 193 | tanksLayer.removeChildren(); 194 | underlay.clear(); 195 | 196 | shells = {}; 197 | level++; 198 | game.level(level); 199 | game.replaceAI('Твой танк', window.editor.getSession().getValue()); 200 | }); 201 | 202 | game.on('levelFail', function(e) { 203 | tanksLayer.removeChildren(); 204 | underlay.clear(); 205 | 206 | shells = {}; 207 | game.level(level); 208 | game.replaceAI('Твой танк', window.editor.getSession().getValue()); 209 | }); 210 | 211 | game.on('levelRestart', function(e) { 212 | tanksLayer.removeChildren(); 213 | underlay.clear(); 214 | 215 | shells = {}; 216 | game.replaceAI('Твой танк', window.editor.getSession().getValue()); 217 | }); 218 | 219 | game.on('score', function(e) { 220 | // console.log('e', e); 221 | }); 222 | setTimeout(function() { 223 | game.run(); 224 | animationFrame(); 225 | }, 500); 226 | }); -------------------------------------------------------------------------------- /example/sender.js: -------------------------------------------------------------------------------- 1 | $(function() { 2 | $('.ai__send').on('click', function() { 3 | var code = window.editor.getSession().getValue(); // Весь текст 4 | game.restart(); 5 | game.replaceAI('Твой танк', code); 6 | }); 7 | 8 | window.editor.on('change', function(e) { 9 | // var code = window.editor.getSession().getValue(); // Весь текст 10 | // game.replaceAI('you', code); 11 | }); 12 | 13 | var socket = io.connect('http://localhost:3010'); 14 | socket.emit('enter', {roomName: 'dima'}); 15 | socket.on('frame', function (data) { 16 | console.log(data); 17 | }); 18 | }); -------------------------------------------------------------------------------- /example/style.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | padding: 0; 3 | margin: 0; 4 | background: #333; 5 | } 6 | 7 | .tabs { 8 | position: absolute; 9 | z-index: 1; 10 | top: 16px; 11 | right: 16px; 12 | width: 32px; 13 | } 14 | .tabs__tab { 15 | position: relative; 16 | background: rgba(163, 204, 78, .6); 17 | width: 32px; 18 | height: 32px; 19 | cursor: pointer; 20 | } 21 | .tabs__tab:first-of-type { 22 | border-radius: 3px 3px 0 0; 23 | } 24 | .tabs__tab:last-of-type { 25 | border-radius: 0 0 3px 3px; 26 | } 27 | .tabs__tab:hover, 28 | .tabs__tab._opened { 29 | background: rgba(163, 204, 78, 1); 30 | } 31 | .tabs__tab:before { 32 | content: ''; 33 | position: absolute; 34 | top: 0; 35 | left: 0; 36 | width: 100%; 37 | height: 100%; 38 | background-size: 100%; 39 | } 40 | .tabs__game:before { 41 | background-image: url('assets/score.svg'); 42 | width: 24px; 43 | height: 24px; 44 | top: 4px; 45 | left: 4px; 46 | } 47 | .tabs__ai:before { 48 | background-image: url('assets/brain.svg'); 49 | } 50 | .tabs__map:before { 51 | background-image: url('assets/tank.svg'); 52 | } 53 | 54 | .map { 55 | display: none; 56 | position: absolute; 57 | top: 24px; 58 | left: 24px; 59 | background: #000; 60 | transform-origin: 50% 0; 61 | } 62 | .map._opened { 63 | display: block; 64 | } 65 | 66 | .player { 67 | position: absolute; 68 | width: 23px; 69 | height: 23px; 70 | background-color: #fcc; 71 | transition: 72 | top .2s linear, 73 | left .2s linear; 74 | } 75 | .player._0 { 76 | background-color: #f99; 77 | color: #f99; 78 | } 79 | .player._1 { 80 | background-color: #9f9; 81 | color: #9f9; 82 | } 83 | .player._2 { 84 | background-color: #88f; 85 | color: #88f; 86 | } 87 | .player._3 { 88 | background-color: #ff8; 89 | color: #ff8; 90 | } 91 | .player._4 { 92 | background-color: #f8f; 93 | color: #f8f; 94 | } 95 | .player._5 { 96 | background-color: #8ff; 97 | color: #8ff; 98 | } 99 | .player._6 { 100 | background-color: #f88; 101 | color: #f88; 102 | } 103 | .player._7 { 104 | background-color: #f98; 105 | color: #f98; 106 | } 107 | .player._8 { 108 | background-color: #9f8; 109 | color: #9f8; 110 | } 111 | .player._dima { 112 | background: #afa; 113 | } 114 | .player._killed { 115 | opacity: .3; 116 | background: red; 117 | transform: scale(1.4); 118 | transition: 119 | opacity .3s, 120 | transform .3s; 121 | } 122 | .player:before { 123 | content: attr(data-name); 124 | position: absolute; 125 | top: -12px; 126 | left: 100%; 127 | font-size: 11px; 128 | font-family: Arial; 129 | color: inherit; 130 | transform: rotate(-33deg); 131 | } 132 | .player:after { 133 | content: ''; 134 | position: absolute; 135 | top: -1px; 136 | left: -1px; 137 | width: 25px; 138 | height: 25px; 139 | background-image: url('assets/tank2.png'); 140 | background-size: 100% 100%; 141 | } 142 | .player._left:after { 143 | transform: rotate(-90deg); 144 | } 145 | .player._right:after { 146 | transform: rotate(90deg); 147 | } 148 | .player._down:after { 149 | transform: rotate(180deg); 150 | } 151 | .player__health { 152 | position: absolute; 153 | top: -6px; 154 | width: 100%; 155 | height: 2px; 156 | background: red; 157 | } 158 | .player__aura { 159 | display: none; 160 | position: absolute; 161 | border: 1px solid #fff; 162 | border-radius: 50%; 163 | width: 150%; 164 | height: 150%; 165 | top: -30%; 166 | left: -30%; 167 | } 168 | .player._immortal .player__aura { 169 | display: block; 170 | } 171 | 172 | .shell { 173 | position: absolute; 174 | z-index: 1; 175 | width: 6px; 176 | height: 6px; 177 | margin: -3px 0 0 -3px; 178 | background: #f00; 179 | transition: all .2s linear; 180 | } 181 | 182 | .game { 183 | z-index: 1; 184 | position: absolute; 185 | top: 16px; 186 | right: 64px; 187 | width: 300px; 188 | display: none; 189 | } 190 | .game._opened { 191 | display: block; 192 | padding: 24px; 193 | background: rgba(255, 255, 255, .2); 194 | } 195 | .game__url { 196 | width: 100%; 197 | box-sizing: border-box; 198 | padding: 4px 10px; 199 | } 200 | .game__score { 201 | opacity: .8; 202 | position: relative; 203 | width: 300px; 204 | font-family: Arial; 205 | } 206 | .game__scoreRow { 207 | display: flex; 208 | background: #ddd; 209 | padding: 1px; 210 | } 211 | .game__scoreCell { 212 | width: 33%; 213 | flex: 1 1 auto; 214 | flex-direction: row; 215 | background: rgba(255, 255, 255, .2); 216 | padding: 4px 10px; 217 | } 218 | 219 | .ai { 220 | /*display: none;*/ 221 | z-index: 1; 222 | position: absolute; 223 | top: 24px; 224 | left: 624px; 225 | height: 500px; 226 | width: 600px; 227 | padding: 24px; 228 | background: rgba(255, 255, 255, .2); 229 | box-sizing: border-box; 230 | } 231 | .ai._opened { 232 | display: block; 233 | } 234 | .ai__textfield { 235 | border: 0; 236 | padding: 4px 10px; 237 | box-shadow: 0 0 3px rgba(0, 0, 0, .1) inset; 238 | margin: 0 10px 10px 0; 239 | } 240 | .ai__function { 241 | position: absolute; 242 | border: 0; 243 | padding: 0; 244 | top: 24px; 245 | left: 24px; 246 | width: 552px; 247 | bottom: 48px; 248 | font-family: 'Courier New'; 249 | } 250 | .ai__send { 251 | position: absolute; 252 | bottom: 24px; 253 | height: 20px; 254 | } -------------------------------------------------------------------------------- /example/tabs.js: -------------------------------------------------------------------------------- 1 | $(function() { 2 | function toggle(modules, show) { 3 | var method = show ? 'addClass' : 'removeClass'; 4 | 5 | if (!_.isArray(modules)) { 6 | modules = [modules]; 7 | } 8 | 9 | _.each(modules, function(m) { 10 | $('.' + m)[method]('_opened'); 11 | $('.tabs__' + m)[method]('_opened'); 12 | }); 13 | 14 | } 15 | 16 | $('.tabs__game').click(function() { 17 | toggle('ai', false); 18 | toggle('game', !$(this).hasClass('_opened')); 19 | }); 20 | 21 | $('.tabs__ai').click(function() { 22 | toggle('game', false); 23 | toggle('ai', !$(this).hasClass('_opened')); 24 | }); 25 | 26 | $('.tabs__map').click(function() { 27 | // toggle(['game', 'ai'], false); 28 | toggle('map', !$(this).hasClass('_opened')); 29 | }); 30 | }); -------------------------------------------------------------------------------- /example/tank.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | var angles = { 4 | right: Math.PI/2, 5 | down: Math.PI, 6 | left: Math.PI/2*3, 7 | up: 0 8 | }; 9 | 10 | var PIXI = require('pixi.js'); 11 | 12 | function Tank(name, texture) { 13 | PIXI.DisplayObjectContainer.call(this); 14 | 15 | this.underlay = new PIXI.Graphics(); 16 | this.overlay = new PIXI.Graphics(); 17 | var sprite = this.sprite = new TankSprite(texture); 18 | 19 | var color = randomColor(); 20 | this.health = 100; 21 | 22 | var text = this.text = new PIXI.Text(name, { 23 | 'font': 'normal 11px Arial', 24 | 'fill': '#' + color.toString(16) 25 | }); 26 | text.rotation = -Math.PI/7.8; 27 | text.position.x = sprite.width + 0.4; 28 | text.position.y = -1; 29 | 30 | this.setColor(color); 31 | 32 | this.addChild(this.underlay); 33 | this.addChild(sprite); 34 | this.addChild(text); 35 | this.addChild(this.overlay); 36 | } 37 | 38 | // constructor 39 | Tank.prototype = Object.create( PIXI.DisplayObjectContainer.prototype ); 40 | Tank.prototype.constructor = Tank; 41 | 42 | Tank.prototype.setColor = function(color) { 43 | this.color = color; 44 | this.sprite.tint = color; 45 | this.drawUnderlay(); 46 | }; 47 | 48 | Tank.prototype.setImmortal = function(immortal) { 49 | this.immortal = immortal; 50 | this.drawOverlay(); 51 | }; 52 | 53 | Tank.prototype.drawUnderlay = function() { 54 | var g = this.underlay, 55 | sprite = this.sprite; 56 | 57 | var w = sprite.width, 58 | h = sprite.height; 59 | 60 | g.clear(); 61 | g.beginFill(this.color, 1); 62 | g.drawRect(0, 0, w, h); 63 | 64 | g.beginFill(0xFF0000, 1); 65 | g.drawRect(0, -5, this._health*w, 2); 66 | 67 | g.endFill(); 68 | }; 69 | 70 | Tank.prototype.drawOverlay = function() { 71 | var g = this.overlay, 72 | sprite = this.sprite; 73 | 74 | var w = sprite.width, 75 | h = sprite.height; 76 | 77 | g.clear(); 78 | if (this.immortal) { 79 | g.lineStyle(2, 0xFFFFFF, 0.66); 80 | g.drawCircle(w/2, h/2, w*0.8); 81 | } 82 | }; 83 | 84 | Tank.prototype.rotate = function(dir) { 85 | this.sprite.rotation = angles[dir] || 0; 86 | }; 87 | 88 | Object.defineProperty(Tank.prototype, 'health', { 89 | get: function() { 90 | return this._health*100; 91 | }, 92 | set: function(percent) { 93 | this._health = percent/100; 94 | this.drawUnderlay(); 95 | } 96 | }); 97 | 98 | function TankSprite(texture) { 99 | PIXI.Sprite.call(this, texture); 100 | 101 | this.anchor.set(0.5, 0.5); 102 | this.width = 25; 103 | this.height = 25; 104 | 105 | this.position.x = 25*0.5; 106 | this.position.y = 25*0.5; 107 | } 108 | 109 | // constructor 110 | TankSprite.prototype = Object.create( PIXI.Sprite.prototype ); 111 | TankSprite.prototype.constructor = TankSprite; 112 | 113 | function randi(start, stop) { 114 | return start + Math.floor(Math.random()*(stop - start)); 115 | } 116 | 117 | 118 | function randomColor() { 119 | var colors = [0x88 ,0x99, 0xFF]; 120 | 121 | return (colors[randi(0, 2)] << 16) + 122 | (colors[randi(0, 2)] << 8) + 123 | colors[randi(0, 2)]; 124 | } 125 | 126 | module.exports = Tank; 127 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'); 2 | var browserify = require('browserify'); 3 | var source = require('vinyl-source-stream'); 4 | var glob = require('glob').sync; 5 | var _ = require('lodash'); 6 | var mocha = require('gulp-mocha'); 7 | var babelify = require("babelify"); 8 | 9 | var paths = { 10 | battlegis: [['config.js'], 'engine/*.js', 'bots/*.js', 'example/*.js'] 11 | }; 12 | 13 | gulp.task('default', ['build', 'watch']); 14 | 15 | function build(engineOnly) { 16 | var entries = [ 17 | './example/client' 18 | ]; 19 | if (!engineOnly) 20 | entries.push('./example/map'); 21 | 22 | var b = browserify(entries); 23 | var bots = glob('./bots/*.js'); 24 | 25 | bots = _.difference(bots, ['./bots/proto.js']); 26 | 27 | bots.forEach(function(fn) { 28 | b.require(fn, { 29 | expose: '.' + fn 30 | }); 31 | }); 32 | 33 | return b 34 | .require('events') 35 | .transform(babelify) 36 | .bundle() 37 | .pipe(source(engineOnly ? 'engine.js' : 'battlegis.js')) 38 | .pipe(gulp.dest('./build/')); 39 | } 40 | 41 | gulp.task('build.engine', _.partial(build, true)); 42 | gulp.task('build.client', _.partial(build, false)); 43 | 44 | gulp.task('build', ['build.engine', 'build.client']); 45 | 46 | gulp.task('test', function () { 47 | return gulp.src('./**/*.spec.js', {read: false}) 48 | .pipe(mocha()); 49 | }); 50 | 51 | gulp.task('watch', function() { 52 | gulp.watch(paths.battlegis, ['build.client']); 53 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tanks", 3 | "version": "0.0.1", 4 | "description": "realtime js ai engine", 5 | "repository": "git@github.com:diokuz/battlegis.git", 6 | "main": "index.js", 7 | "scripts": { 8 | "start": "gulp", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "author": "Diokuz", 12 | "license": "MIT", 13 | "dependencies": { 14 | "ace": "git://github.com/ajaxorg/ace-builds.git#a4e495d8901876c6bafe3870a35cb8e32c827e97", 15 | "body-parser": "^1.10.1", 16 | "express": "^4.10.7", 17 | "line-intersect": "^1.0.0", 18 | "lodash": "^2.4.1", 19 | "pixi.js": "git://github.com/GoodBoyDigital/pixi.js", 20 | "robust-segment-intersect": "^1.0.1", 21 | "socket.io": "^1.3.4" 22 | }, 23 | "devDependencies": { 24 | "babelify": "^6.1.2", 25 | "browserify": "^8.1.1", 26 | "flat-glob": "0.0.1", 27 | "glob": "^4.3.5", 28 | "gulp": "^3.8.10", 29 | "gulp-mocha": "^2.0.0", 30 | "sinon": "^1.13.0", 31 | "vinyl-source-stream": "^1.0.0" 32 | }, 33 | "browser": "./example/client.js" 34 | } 35 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/2gis/Battlegis.svg?branch=master)](https://travis-ci.org/2gis/Battlegis) 2 | 3 | # Движок для создания танков на js 4 | 5 | ## Локальный сервер 6 | 7 | Понадобится node версии 0.12+. 8 | ```bash 9 | npm install 10 | npm start 11 | ``` 12 | 13 | Затем в отдельной вкладке (в будущем этот шаг уберём) 14 | 15 | ```bash 16 | node --harmony server 17 | ``` 18 | 19 | После этого игра доступна по адресу [localhost:3009](http://localhost:3009) 20 | 21 | ## API танков 22 | 23 | Приложение зациклино через setInterval(tick, 200). Каждый тик выполняет: ai всех ботов; пересчёт положений снарядов и ботов, включая коллизии, попадания, подбор поверапов и смерти. 24 | 25 | AI - это js-функция, вызванная в контексте инстанса бота. 26 | 27 | ### Методы 28 | ```js 29 | // Направления 30 | this.left(); 31 | this.up(); 32 | this.right(); 33 | this.down(); 34 | ``` 35 | При вызове любого метода направления танк поворачивается в соответствующую сторону и проезжает в эту сторону 1 условный пиксель. Если за 1 тик вызвать несколько методов передвижения, реально вызовется только последний (например в коде выше это this.down). Если ни один из методов не был вызван, танк остановится в направлении предыдущего движения. 36 | ```js 37 | // Огонь 38 | this.fire(); 39 | ``` 40 | Попытка выстрелить. Огонь ведётся только в направлении движения (башня не крутится). На старте у бота есть 3 снаряда, которые можно расстрелять за три следующих тика. Снаряды восстанавливаются по 1 штуке за 10 тиков до максимального значения в 3 снаряда. 41 | ```js 42 | // Ускорение 43 | this.nitro(); 44 | ``` 45 | Резкое ускорение в направлении движения. На старте у бота есть 6 литров закиси азота, которые можно потратить за 6 следующих тиков. Затем закись восстанавливается по 1 литру за 10 тиков, однако вызов метода nitro ставит на паузу это восстановление на 10 тиков. Иными словами, если постоянно дёргать метод, ускорения не будет вообще. 46 | ```js 47 | // Противник 48 | this.enemy; 49 | ``` 50 | Это поле, в котором хранится текущий преследуемый противник. Назначается автоматически. Можно задать вручную через метод 51 | ```js 52 | // Преследование противника 53 | this.pursue(enemyId || undefined); 54 | ``` 55 | Записывает в this.enemy случайного бота, либо переданного в первом аргументе, и начинает его преследовать. По сути, this.pursue - это автоматический вызов одного из четырёх методов передвижения для максимально быстрого выхода на одну линию с противником. 56 | ```js 57 | // Убегание 58 | this.escape(enemyId || undefined); 59 | ``` 60 | Автоматический вызов одного из четырёх методов передвижения для максимального удаления от текущего противника. 61 | 62 | У танка есть набор флагов, характеризующих его состояние: 63 | ```js 64 | this.health; // Здоровье танка, 0-100. 65 | this.armed; // Параметр готовности стрельбы, 0-30. Выстрел отнимает 10. 66 | this.immortal; // Число тиков до потери бессмертия. При попытке выстрелить бессмертие теряется мгновенно. 67 | this.stamina; // Запас стамины делённый на 10 - количество тиков, в течение которых можно использовать метод nitro. Вызов метода nitro отменяет восстановление стамины на 10 тиков. 0-60. 68 | ``` 69 | 70 | Особый набор флагов находится в объекте 71 | ```js 72 | this.powerups == { 73 | '2gisDamage': 25 74 | }; 75 | ``` 76 | В этом примере у танка есть '2gisDamage', который будет сохраняться следующие 25 тиков (либо до смерти). То есть следующие 25 тиков у снарядов танка будет двойной урон. 77 | 78 | ### Данные о текущей игре 79 | Все данные о других танках, границах карты, летящих снарядах и поверапах находятся в объекте frame, который имеет следующую структуру: 80 | ```js 81 | this.frame = { 82 | // Живые боты на карте 83 | players: [{ 84 | id: 123, // Уникальный идентификатор бота 85 | name: 'overmind', // Никнейм бота 86 | x: 46, // Положение бота, карта обычно размером 100 на 120 87 | y: 84, 88 | width: 5, // Стандартный размер танка 89 | height: 5, 90 | health: 100, // Здоровье бота, максимум 100 91 | immortal: 6, // Количество тиков до потери бессмертия 92 | // Массив поверапов, в данном тике подцепленных игроком 93 | powerups: [{ 94 | type: '2gisDamage', 95 | left: 12 // Количество тиков, после которого поверап кончится 96 | }], 97 | }], 98 | enemy: ... // Случайный бот, являющийся текущей целью 99 | // Массив летящих снарядов 100 | shells: [{ 101 | id: '123', // Уникальный идентификатор снаряда 102 | vector: [0, 1], // Направление полёта снаряда, в данном случае вниз (Y направлен вниз) 103 | x: 123, 104 | y: 25, 105 | shooter: '123' // Идентификатор бота, который выпустил этот снаряд 106 | }], 107 | // Массив поверапов, в данном тике находящихся на карте 108 | powerups: [{ 109 | type: '2gisDamage', 110 | left: 12 111 | }], 112 | map: { 113 | size: { // Размеры карты 114 | x: 100, 115 | y: 120 116 | } 117 | } 118 | } 119 | ``` 120 | 121 | ### Определение своих методов 122 | Для структурирования кода бота полезно выносить какие-то блоки кода в отдельные функции или методы самого бота. Поскольку код выполняется каждый тик, для экономии ресурсов рекомендуется использовать следующий паттерн: 123 | ```js 124 | this.fireForSure = this.fireForSure || function() { 125 | if (this.locked()) this.fire(); 126 | } 127 | 128 | this.pursue(); 129 | this.fireForSure(); // Будет стрелять только когда противник на линии огня 130 | ``` 131 | В таком коде метод создастся только раз при первом выполнении. 132 | 133 | В прототипе инстанса бота есть методы типа pursue и escape, но они не идеальны и их можно переопределять указанным способом. 134 | 135 | ### Рандомный бот 136 | 137 | Самый простой бот, который передвигается в случайном направлении и постоянно стреляет: 138 | ```js 139 | var moves = ['left', 'up', 'right', 'down']; 140 | var method = moves[Math.foor(Math.random() * 4)]; 141 | 142 | this[method](); 143 | this.fire(); 144 | ``` 145 | 146 | ## Физика 147 | 148 | Все размеры - условные пиксели. Это могут быть сантиметры, километры или парсеки, не важно. Но для простоты будем называть эти единицы пикселями. Главное понимать, что это не экранные, логические, физические или CSS пиксели. Это просто условные единицы длины. 149 | 150 | У бота есть скорость. При простом передвижении она равняется 1 пиксель в тик, но при ускорении она повышается. При скорости больше 2 у танка появляется инерция: при повороте его будет заносить. Существует отдача, которая добавляет вектор скорости 1 в сторону, противоположную выстрелу. 151 | 152 | Бот может стрелять когда его параметр armed больше 10. Он увеличивается каждый тик и может достигать максимум 30. Каждый выстрел уменьшает его на 10. То есть, на старте или после долгого перерыва можно быстро сделать три выстрела, но потом скорострельност резко снижается до 1 в 10 тиков. 153 | 154 | Бот не может выехать за пределы карты. Его перемещение также ограничено другими ботами. Координаты любого бота - это координаты его левого верхнего угла. 155 | 156 | После респавна у бота есть бессмертие - параметр immortal имеющий значение 10 и уменьшающийся на 1 каждый тик. Он обнуляется мгновенно при попытке выстрелить. 157 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var bodyParser = require('body-parser'); // Для распарсивания POST-запросов 3 | var app = express(); 4 | var ioServer = require('http').Server(app); 5 | ioServer.listen(3010); 6 | 7 | var io = require('socket.io')(ioServer); 8 | var config = require('./config'); 9 | var RoomManager = require('./server/roomManager'); 10 | var roomManager = new RoomManager(); 11 | 12 | app.use( bodyParser.json() ); // to support JSON-encoded bodies 13 | app.use(bodyParser.urlencoded({ // to support URL-encoded bodies 14 | extended: true 15 | })); 16 | 17 | app.use('/', express.static(__dirname + '/example')); 18 | // app.use('/game', require('./server/roomManager')); 19 | app.use('/build', express.static(__dirname + '/build')); 20 | app.use('/node_modules', express.static(__dirname + '/node_modules')); 21 | 22 | 23 | var server = app.listen(config.port || 3009, function () { 24 | var host = server.address().address; 25 | var port = server.address().port; 26 | 27 | console.log('Tanks app listening at http://%s:%s', host, port); 28 | }); 29 | 30 | io.on('connection', function (socket) { 31 | function joinRoom(params) { 32 | params = params || {}; 33 | var room = roomManager.getRoom(params.id); 34 | 35 | if (!room) throw new Error('room with id ' + params.id + 'not found'); 36 | 37 | room.join(params.name, params.sessionId); 38 | 39 | socket.emit('roomJoined', { 40 | id: room.id, 41 | map: room.map 42 | }); 43 | 44 | room.on('frame', function(frame) { 45 | socket.emit('frame', frame); 46 | }); 47 | }; 48 | 49 | // Создать комнату 50 | socket.on('create', function (data) { 51 | data = data || {}; 52 | var room = roomManager.create(); 53 | 54 | if (room) { 55 | socket.emit('roomCreated', { 56 | id: room.id 57 | }); 58 | 59 | data.id = room.id; 60 | 61 | joinRoom(data); 62 | } 63 | }); 64 | 65 | // Зайти в комнату спектатором 66 | socket.on('join', joinRoom); 67 | 68 | // Попробовать запустить бота на карту (если есть слоты) 69 | socket.on('fight', function (params) { 70 | var room = roomManager.getRoom(params.id); 71 | 72 | console.log('params', params); 73 | var position = room.fight(params.name, params.sessionId, params.ai); 74 | 75 | if (position) { 76 | socket.emit('queue', { 77 | position: position 78 | }); 79 | } else { 80 | socket.emit('arenaJoined'); 81 | } 82 | }); 83 | }); -------------------------------------------------------------------------------- /server/room.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var EventEmitter = require('events').EventEmitter; 3 | var inherits = require('util').inherits; 4 | 5 | var Battlegis = require('../engine'); 6 | var config = require('../config'); 7 | 8 | function Room(params) { 9 | params = params || {}; 10 | 11 | this.config = params.config || config; 12 | this.game = new Battlegis(_.cloneDeep(this.config)); 13 | this.game.level('arena' || params.level); 14 | this.game.run(); 15 | this.id = params.id; 16 | this.game.roomId = params.id; 17 | this.users = []; 18 | this.map = this.game.map; 19 | 20 | var self = this; 21 | this.game.on('frame', function(frame) { 22 | self.emit('frame', frame); 23 | }); 24 | 25 | this.game.on('levelComplete', function() { 26 | self.nextGame(); 27 | }); 28 | }; 29 | 30 | inherits(Room, EventEmitter); 31 | 32 | Room.prototype.dispose = function() { 33 | this.game.stop(); 34 | }; 35 | 36 | // Законнектиться в комнату в качестве спектатора 37 | Room.prototype.join = function(name, pass, ai) { 38 | console.log('name', name); 39 | if (this.users[name]) return; 40 | 41 | this.users[name] = { 42 | name: name, 43 | pass: pass, 44 | ai: ai || '', 45 | mode: 'spectator' 46 | }; 47 | }; 48 | 49 | // Уйти из комнаты совсем 50 | Room.prototype.disconnect = function(name, pass) { 51 | delete this.users[name]; 52 | }; 53 | 54 | // Спектатор с именем name пытается присоединиться к игре 55 | Room.prototype.fight = function(name, pass, ai) { 56 | var user = this.users[name]; 57 | if (!user) throw new Error('No user ' + name + ' found in the room ' + this.name); 58 | if (!pass || user.pass != pass) throw new Error('Incorrect sessionId for user ' + name); 59 | 60 | var players = _.filter(this.users, function(user) { 61 | return user.mode == 'player'; 62 | }); 63 | 64 | if (players.length < this.config.maxPlayers) { 65 | // Находим нашего бота, которого можно исключить в пользу юзерского бота 66 | // var tempBot = _.find(this.game.bots, function(bot) { 67 | // return bot; // ? 68 | // }); 69 | 70 | // this.game.remove(tempBot); 71 | this.game.add({ 72 | name: name, 73 | ai: ai 74 | }); 75 | this.users[name].mode = 'player'; 76 | } else { 77 | // Число игроков уже дофига, ограничиваем текущий матч 3 минутами 78 | this.game.timeout(3 * 60 * 1000); 79 | this.queue.push(this.users[name]); 80 | 81 | return this.queue.length; 82 | } 83 | }; 84 | 85 | // Уйти с карты но остаться в комнате 86 | Room.prototype.spectate = function(name, pass) { 87 | this.users[name].mode = 'spectator'; 88 | this.game.remove(name); 89 | }; 90 | 91 | // Запустить следующую игру 92 | Room.prototype.nextGame = function() { 93 | if (this.queue.length) { 94 | // Игроки, которые попадут в новую игру 95 | var queuedPlayers = this.queue.splice(0, - this.config.maxPlayers - this.config.saveBestPlayersNum); 96 | 97 | var worstPlayers = _.filter(this.game.bots, function(bot) { 98 | return bot.kill * 1000000 - bot.death; 99 | }, this).slice(0, this.config.maxPlayers - this.config.saveBestPlayersNum); 100 | 101 | this.queue = this.queue.concat(worstPlayers); // Удалённых игроков ставим в конец очереди 102 | 103 | _.each(worstPlayers, function(botToRemove) { 104 | this.remove(botToRemove); 105 | }, this); 106 | 107 | _.each(queuedPlayers, function(botToAdd) { 108 | this.add(botToAdd); 109 | }, this); 110 | } 111 | 112 | this.game.restart(); 113 | }; 114 | 115 | Room.prototype.replaceAI = function() {}; 116 | 117 | module.exports = Room; -------------------------------------------------------------------------------- /server/roomManager.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var Room = require('./room'); 3 | var config = require('../config'); 4 | var inherits = require('util').inherits; 5 | var EventEmitter = require('events').EventEmitter; 6 | 7 | inherits(RoomManager, EventEmitter); 8 | 9 | function RoomManager() { 10 | this.rooms = []; 11 | this.roomsNumber = 0; 12 | } 13 | 14 | RoomManager.prototype.create = function() { 15 | var id = this.roomsNumber++; 16 | console.log('id', id); 17 | 18 | if (this.rooms[id]) return; 19 | 20 | this.rooms[id] = new Room({ 21 | id: id 22 | }); 23 | 24 | return this.rooms[id]; 25 | }; 26 | 27 | RoomManager.prototype.dispose = function(id) { 28 | this.rooms[id] && this.rooms[id].dispose(); 29 | this.roomsNumber--; 30 | }; 31 | 32 | RoomManager.prototype.getRoom = function(id) { 33 | return this.rooms[id]; 34 | }; 35 | 36 | module.exports = RoomManager; --------------------------------------------------------------------------------