Какое-то время назад решил написать небольшое приложение, чтобы потренироваться работе с вебсокетами. Из питоновских фреймворков мне показалось удобней изкоробочная поддержка их в tornado. Поскольку игрушка предельно простая, может кому-то показаться полезной как пример. Это многопользовательская «змейка».



Весь «фронт» умещается в один html файл. Вот скрипт из него

    var canvas = document.getElementById('canvas'),
    c = canvas.getContext('2d'),
    direction='up', nick='Anonymous';
    c.lineWidth = 1;
    var snakes=[
    ];
    var apples=[
        [10,10],
        [2,2]
    ];
    var colors = ['red', 'blue', 'green', 'black', 'purple', 'teal', 'navy', 'lime', 'olive', 'maroon', 'aqua']
    function redraw(){
        c.clearRect(0, 0, canvas.width, canvas.height);
        c.stroke();
        for(var j=0; j<snakes.length; j++){
            c.fillStyle=colors[j];
        for(var i=0; i<snakes[j].length; i++) {
        c.fillRect(snakes[j][i][0]*10,snakes[j][i][1]*10, 10,10);
        }
            c.stroke();
        }
        for(var i=0; i<apples.length; i++){
            c.strokeStyle="#FF00DD";
            c.beginPath();
            c.arc(apples[i][0]*10+5,apples[i][1]*10+5,5,0,2*Math.PI);
            c.stroke();
        }
    }
    var updater = {
    socket: null,

    start: function() {
        if(updater.socket && updater.socket.readyState !== updater.socket.CLOSED) return;
        var url = "ws://" + location.host + "/gamesocket";
        updater.socket = new ReconnectingWebSocket(url);
        updater.socket.onmessage = function(event) {
            updater.showMessage(JSON.parse(event.data));
            redraw();
        }
        updater.socket.onopen = function(event){
            updater.socket.send(JSON.stringify({'nick': nick}));
        }
    },
    showMessage: function(message) {
        snakes = message.snakes;
        apples = message.apples;
        document.getElementById('scores').innerHTML="";
            for(var i=0; i<message.scores.length; i++){
            document.getElementById('scores').innerHTML+=message.scores[i][0]+': '+message.scores[i][1]+'<br>';
            }
    }
    }

Мы получаем координаты всех змеек и яблок из вебсокета и рисуем на canvas.

    document.onkeydown = function(e){
        var keys = {37:'left', 39:'right', 38:'up', 40:'down'};
        var k = keys[e.keyCode];
        if(k && k != direction){
            if(direction == 'up' && k == 'down') return;
            if(direction == 'down' && k == 'up') return;
            if(direction == 'left' && k == 'right') return;
            if(direction == 'right' && k == 'left') return;
            direction = keys[e.keyCode];
            updater.socket.send(JSON.stringify({direction:direction}));
        }
    }
    window.onload = function(){
        nick = window.prompt("Enter your name","Anonymous");
        if(!nick) nick = 'Anonymous';
        updater.start();
    }

При нажатии стрелок мы отправляем новое направление движения на сервер(если оно изменилось).

Теперь, серверная часть. Она тоже небольшая.

import logging
import tornado.escape
import tornado.ioloop
import tornado.options
import tornado.web
import tornado.websocket
from tornado import gen
import os.path
import uuid
import json
import random
from datetime import datetime

from tornado.options import define, options

SIZE = 100, 60
define("port", default=8000, help="run on the given port", type=int)

class Application(tornado.web.Application):
    def __init__(self):
        handlers = [
            (r"/", IndexHandler),
            (r"/gamesocket", GameSocketHandler),
            (r"/files/(.*)", tornado.web.StaticFileHandler, {'path': 'files'}),
        ]
        settings = dict(
            cookie_secret="__TODO:_GENERATE_YOUR_OWN_RANDOM_VALUE_HERE__",
            template_path=os.path.join(os.path.dirname(__file__), "templates"),
            static_path=os.path.join(os.path.dirname(__file__), "static"),
            xsrf_cookies=True,
            autoreload=True,
            debug=True,
        )
        tornado.web.Application.__init__(self, handlers, **settings)

class IndexHandler(tornado.web.RequestHandler):
    def get(self):
        self.render("index.html")


class GameSocketHandler(tornado.websocket.WebSocketHandler):
    players = set()
    apples = [[random.randint(0, SIZE[0]), random.randint(0, SIZE[1])]
        for i in range(20)
    ]

    def get_compression_options(self):
        # Non-None enables compression with default options.
        return {}

    def open(self):
        x_real_ip = self.request.headers.get("X-Real-IP")
        self.ip = x_real_ip or self.request.remote_ip
        self.direction = 'up'
        self.score = 0
        self.nick = 'Anonymous'
        self.die()
        GameSocketHandler.players.add(self)
        GameSocketHandler.send_updates()

    def die(self):
        while 1:
            x, y = random.randint(0, SIZE[0]), random.randint(0, SIZE[1])
            if any(x == sx and y == sy for player in GameSocketHandler.players
                for sx, sy in player.snake):
                    continue
            if any(x == sx and y == sy for sx, sy in GameSocketHandler.apples):
                    continue

            break
        self.snake = [[x, y]]

    @classmethod
    def add_apple(cls):
        while 1:
            x, y = random.randint(0, SIZE[0]), random.randint(0, SIZE[1])
            if any(x == sx and y == sy for player in GameSocketHandler.players
                for sx, sy in player.snake):
                    continue
            if any(x == sx and y == sy for sx, sy in GameSocketHandler.apples):
                    continue

            break
        cls.apples.append([x, y])


    def on_close(self):
        GameSocketHandler.players.remove(self)

    @classmethod
    def send_updates(cls):
        #logging.info("sending message to %d waiters", len(cls.players))
        data = {
            'snakes': [player.snake for player in cls.players],
            'apples': cls.apples,
            'scores': [[player.nick, player.score] for player in cls.players]
        }
        for waiter in cls.players:
            try:
                data['head'] = waiter.snake[-1]
                waiter.write_message(data)
            except:
                logging.error("Error sending message", exc_info=True)

    def on_message(self, message):
        message = json.loads(message)
        logging.info("Got message %r" % message)
        if 'direction' in message:
            if message['direction'] not in ['up', 'left', 'right', 'down']:
                return
            self.direction = message['direction']
        elif 'nick' in message:
            self.nick = message['nick'].replace('<', '').replace('>', '')

def game_tick():
    for player in GameSocketHandler.players:
        d = {'up': (0,-1), 'down': (0,1), 'left': (-1, 0), 'right': (1,0)}
        player.snake.append([player.snake[-1][0]+d[player.direction][0], player.snake[-1][1]+d[player.direction][1]])
        if player.snake[-1] in GameSocketHandler.apples:
            GameSocketHandler.apples.remove(player.snake[-1])
            GameSocketHandler.add_apple()
            player.score += 1
        else:
            player.snake.pop(0)
        if player.snake[-1][0] < 0 or player.snake[-1][1] < 0 or player.snake[-1][0] >= SIZE[0] or player.snake[-1][1] >= SIZE[1]:
            player.die()
        for enemy in GameSocketHandler.players:
            if enemy != player and player.snake[-1] in enemy.snake:
                player.die()
        if player.snake[-1] in player.snake[:-1]:
            player.die()
    GameSocketHandler.send_updates()

def main():
    tornado.options.parse_command_line()
    app = Application()
    app.listen(options.port)
    tornado.ioloop.PeriodicCallback(game_tick, 100, io_loop = tornado.ioloop.IOLoop.current()).start()
    tornado.ioloop.IOLoop.current().start()


if __name__ == "__main__":
    main()

Всех игроков мы храним в поле класса GameSocketHandler, а я его экземляры хранят о них данные и связаны с вебсокетом. Каждые 100 милисекунд вызывается функция game_tick, которая двигает змеек и обнаруживает коллизии.

Целиком исходник можно забрать тут

Комментарии (3)


  1. gnunixon
    15.01.2018 22:36

    Код читается легко, но лучше бы комментариев в него добавить и демо выставить куда-нибудь. А так — молодец :-)


  1. Sild
    16.01.2018 09:18

    А чего просто ссылку на гитхаб не вставили? В твиттер бы поместилось.


  1. VioletGiraffe
    16.01.2018 17:19

    А ведь можно было бы добавить объяснений, и получилась бы отличная статья/введение/туториал для начинающих, а также для ничего не смыслящих в Питоне/вебе/вебе на Питоне :)