Как Go спасает инди разработчика (часть 1)
И особенности Data oriented design в Go
Привет! В данный момент я делаю первое онлайновое рпг с адаптивным нарративом. Написание стабильной онлайновой игры малыми командами –интересный челендж.

Вторая часть тут

Я постараюсь показать из чего состоит клиент-серверное общение в моем онлайновом rpg. Одна из проблем – хочется отправлять на клиент минимального размера изменения, но, при этом должна быть возможность моментально сообщить клиенту о текущем состоянии, если он не знает предыдущих. Например, если произошел разрыв соединения и нужно вернуть игрока.
Go для indie game dev
Мы все знаем, что Go очень простой и разрабатывался изначально как язык для гугловских джунов. И именно благодаря простоте - это тот язык, который по-настоящему сияет для разработчика игр.
Рзработка клиентских игр сейчас проста как никогда. Есть множество конструкторов и движков с инструментами вплодь до визуального программирования с которыми делать прототипы очень просто. Но с сетью все намного сложнее и в этих двух статьях попробую показать, чем на практике Go может помочь инди разработчику.

Для инди разработчика, время которое вы проводите за поддержкой сети – это то время, когда ваши заводы стоят и прогресса не видно ни вам ни аудитории. Тут есть три важных параметра: скорость разработки, простота кода и перфоманс. У Go хороший перфоманс, много ограничений заставляют писать, возможно, чуть больше кода, чем у конкурентов, но куда более читаемый код. Именно читаемость кода очень важный параметр для инди разработчика, ведь вам придется заглядывать каждые пару недель и именно отсутствие высокоуровневых абстракций позволяют очень быстро разобраться с тем, что происходит. По-моему наибольшая польза в Go – работая, думаешь о трансформации данных, а не о поведении объектов.

Сеть – это та среда, в которой близость к данным важнее, чем расширенное ООП поведение. В этих двух статья попробую показать, чем Go хорош для игровых серверов.

Небольшое видео из текущей версии:
Protobuf
Бенчмарк времени
сериализации
Google protobuf – это протокол сериализации данных. Смысл такой, вы описываете структуру в отдельном файле .proto, с помощью него protobuf сериализует и десериализует данные. Зачем? Данных получается на много меньше, чем json и кодирует раз в 10 быстрее, что очень критично для сокетов.

Стоит отметить, protobuf не кодирует нули (они заполняются декодером как дефолтные значения). Это очень удобно, ведь в Go все структуры по умолчанию имеют ноль в качестве начального значения.

В центре нашей сетевой игры - авторитарный сервер. Игрок отправляет команды серверу, в черном ящике происходит магия, и сервер формирует сообщения на клиент. Сообщения состоят из полных состояний и дельт. Полные объекты – это, например, инвентарь, персонаж или профиль игрока и т.п. Дельты – это изменения данных на сервере(deltas), которые должны быть проиграны на клиенте в строго определенном порядке, чтобы игрок увидел актуальную игру. Отмечу, один сетевой пакет может содержать до 10 секунд всяких эффектов, анимаций и смены сцен, например.
Серверные эффекты
message ServerEffect {
    ServerEffectType Type = 1;
    int32 Value = 2;
    int32 Place = 3;
    uint32 TargetID = 6;
    uint32 SubType = 5;
}
Для передачи различных дельт я создал protobuf сообщение ServerEffect.
ServerEffect работает так: для игрока TargetID произошел эффект Type в месте Place. Вот некоторые типы серверных эффектов:

type ServerEffects struct {
	Appear               data.ServerEffectType
	UseSkill             data.ServerEffectType
	UseSkillTarget       data.ServerEffectType
	EndUseSkill          data.ServerEffectType
	HealthChange         data.ServerEffectType
	WillChange           data.ServerEffectType
	HealthChangeCritical data.ServerEffectType
	AddBuff              data.ServerEffectType
	RemoveBuff           data.ServerEffectType
	Miss                 data.ServerEffectType
	MoneyDrop            data.ServerEffectType
	AddArgument          data.ServerEffectType
	EnergyChange         data.ServerEffectType
}
Я очень часто использую кодогенерацию, в этой статье я показывал как генерировать декларативные конфиги, которые будут валидными на сервере и на клиенте. Эта структура затем программно нумеруется через reflect и заворачивается в config.js. Таким образом я использую типизированные константы в TypeScript и Go, чтобы в дальнейшем на клиенте можно было написать config.SkillEffects.AddBuff и быть уверенным в актуальности ID.

Этот .proto файл прогоняется через github.com/dcodeIO/ProtoBuf.js/ и github.com/golang/protobuf. На выходе мы получаем типизированный код для typescript и Go:

export class ServerEffect implements IServerEffect {
    constructor(properties?: IServerEffect);
    public Type: ServerEffectType;
    public Value: number;
    public Place: number;
    public TargetID: number;
    public SubType: number;
    public static create(properties?: IServerEffect): ServerEffect;
    public static encode(message: IServerEffect, writer?: $protobuf.Writer): $protobuf.Writer;
    public static encodeDelimited(message: IServerEffect, writer?: $protobuf.Writer): $protobuf.Writer;
    public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): ServerEffect;
    public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): ServerEffect;
    public static verify(message: { [k: string]: any }): (string|null);
    public static fromObject(object: { [k: string]: any }): ServerEffect;
    public static toObject(message: ServerEffect, options?: $protobuf.IConversionOptions): { [k: string]: any };
    public toJSON(): { [k: string]: any };
}
type ServerEffect struct {
	Type     ServerEffectType `protobuf:"varint,1,opt,name=Type,enum=ServerEffectType" json:"Type,omitempty"`
	Value    int32            `protobuf:"varint,2,opt,name=Value" json:"Value,omitempty"`
	Place    int32            `protobuf:"varint,3,opt,name=Place" json:"Place,omitempty"`
	TargetID uint32           `protobuf:"varint,6,opt,name=TargetID" json:"TargetID,omitempty"`
	SubType  uint32           `protobuf:"varint,5,opt,name=SubType" json:"SubType,omitempty"`
}

func (m *ServerEffect) Reset()                    { *m = ServerEffect{} }
func (m *ServerEffect) String() string            { return proto.CompactTextString(m) }
func (*ServerEffect) ProtoMessage()               {}
func (*ServerEffect) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{14} }
OOP и прелести DOD (data oriented design)
Если главная маркетинговая фишка Go – это горутины, то концептуальное отличие в том что Go - не ООП, а data oriented язык программирования.

Проблема объекта в том, что он является тесной склейкой из данных и поведения и его текущее состояние бывает тяжело вычленить. И методы, зачастую, это тоже данные, ведь многие языки позволяют переопределять методы у экземпляра.

Помните видео Джонатана Блоу, разработчика Braid, про data oriented язык программирования и анонимную композицию? Он описывает концепцию своего языка, в центре которого используется анонимная композиция.
Большая часть его ухищрений касаются клиентского перфоманса и memory align

Go идет по похожему пути, но для другой цели – удобства коммуникации. В нем есть две независимые вселенные: есть структуры, которые от начала до конца живут своей жизнью и методы над ними. Вызывая x.y(z) в Go, фактически вы вызываете y(x, z) со всеми вытекающими. Структуры группируются с помощью анонимной композиции, методы группируются с помощью интерфейсов. Кстати, для интерфейсов композиция тоже работает. Структура никак не помечает интерфейс: достаточно, чтобы у неё или у её анонимно вложенных детей были определены методы. По-сути это все манёвры, которые у вас есть в Go, если вы хотите писать абстракции "правильно". На одну ступеньку ближе к преисподней начинается кодогенерация дженериков через github.com/cheekybits/genny, использование библиотеки reflect и прочие извращения.

Прелесть этого подхода в том, что если мы сериализировали какую-то структуру, которая нам прилетела по сети, или мы прочитали json документ, мы будем на 100% уверены, что ее методы готовы к использованию и корректно отработают. Такие структуры легче создавать, пулить, копировать и откатывать до начального состояния.

Чтобы понять, в чем разница между объектами, которые содержат методы (ООП) и данными, над которыми выполняются методы, приведу простой пример. В Go можно вызывать методы у nil структуры, что, кстати, конфузит разработчиков.
Работа в Go с нулевыми указателями
Давайте вернемся к нашим серверным эффектам и сделаем два алиаса
ServerEffect, – для одного эффекта и для списка FList:
type FList []*data.ServerEffect
type ServerEffect data.ServerEffect
И вспомним 2 особенности append в Go:

1. Иногда он создает новый слайс, а иногда использует старый (в зависимости от длины и вместимости(capacity)), поэтому добавляются элементы всегда так
x = append(x, y)

Опишем два метода над FList для склеивания двух слайсов и добавления одного элемента *ServerEffect
func (s FList) AddSingle(f *ServerEffect ) FList {
	if f == nil {
		return s
	}
	return append(s, (*data.ServerEffect)(f))
}

func (s FList) Add(ss FList) FList {
	return append(s, ss...)
}
2. Он прекрасно работает над нулевыми указателями, таким образом будет валидной такая запись:
	var x, y FList
	x = x.Add(y)
На выходе x будет указателем на nil, а фан в том, что логика отработает, но ни одного слайса выделено не будет!

Например, один из навыков работает вот так:
type ArmorSkill struct{}

func (a *ArmorSkill) UseOnTarget(
          bs *BaseSkill,        target *BaseCharacter, 
          owner *BaseCharacter, targetPlace int32) FList {

	damage := 1 + (bs.EffectPower / 100.) * (target.MaxHP - target.HP)

	return  target.heal(damage, damage, nil, bs).Add(
		target.addBuff(confBuffIDs.ChangeArmor, target, bs.DurationTurns, int32(bs.CustomValue), false, bs))
}
target.heal вылечит наш target и вернет список эффектов (или nil, по каким-то причинам), а поверх этого списка (или nil) мы вызовем Add и попытаемся добавить баф. Более того, валидной будет и такая запись:
	return FList(nil).AddSingle(&ServerEffect {})
Устройство клиента
Список всех эффектов выплевывается вебсокетами на клиент, теперь нужно применить все серверные эффекты на клиенте. Про то, как он выплевывается - читайте во второй части.

Вот так выглядит центральная часть нашего клиента, он парсит эффекты и применяет изменения.
  renderEffect(x: IServerEffect, serverData: ServerData, playerData: IPlayerObject, accountData: IAccountGeneral) {
        let targetid = x.TargetID;
        switch (x.Type) {
            case config.Skills.ServerEffects.NewRoom:
                this.do(() => {
                    _.game.NewRoom(serverData, playerData, accountData);
                });
                break;
            case config.Skills.ServerEffects.StartEnemyTurn:
                this.do(() => {
                    _.pa.ShowTurnAnimation("Enemy turn", true);
                }, config.Player.TurnNotificationDelay);
                break;

            case config.Skills.ServerEffects.TurnEnd:
                this.do(() => {
                    network.sendPlayerCommand(config.Commands.CMD_ANIMATION_ENDED)
                });
                break;

            case config.Skills.ServerEffects.ChangeState:
                let time = (new Date()).getTime();
                this.do(() => {
                    this.updateTurnState(x.Value, serverData, playerData, accountData, time);
                });
                break;

            case config.Skills.ServerEffects.Overkill:
                this.do(() => {
                    let n = network.getObj(targetid);
                    let target = n ? (<Actor>n.gameObject) : null;
                    _.sm.addTextParticle(config.TextConstants.Overkill, target, 'smallfontp', 0x00ff5511, 0.8)
                });
                break;
....
}
Все эффекты, во всех действиях, полагаются только на данные текущего пакета и ни о чем другом не догадываются. Даже если наш эффект будет применен через 5 секунд, и состояние на клиенте поменяется, он будет работать со старыми данными и это замечательно.

Все кейсы здесь завернуты в асинхронное this.do, сохраняют в замыкании данные текущего пакета и добавляются в очередь. Полагаться на клиентское состояние плохая практика. Может оказаться, что, играя 5 минут, и, аккумулировав большое количество данных на клиенте, у вас и не случится ошибки, а присоединившись в неудачный момент, у вас не окажется каких-то данных и привет. Но обратный кейс еще хуже, у вас не случится ошибки, но данные, которые будут показаны, окажутся устаревшими.

Функция do() нужна здесь потому, что игра пошаговая (с некоторыми оговорками) и одни эффекты должны дожидаться выполнения других и иметь возможность заблокировать новые эффекты.

А во-вторых должна быть возможность проиграть эффекты в ускоренном темпе. Например, для браузерной версии актуальна проблема: хром блокирует requestAnimationFrame(RAF), когда вы покидаете вкладку. Это значит, что все пакеты приходят, JS код выполняется ( частично ), но RAF будет вызван 2 раза в секунду. Так наше клиентское состояние начнет отставать от серверного (с 60 FPS до 2 FPS).

Вторая часть, про то как устроена рассылка сообщений читайте тут:
http://dorogoy.tilda.ws/gosaveindie2
Следить в твиттере за новыми постами @DorogoyYuriy.
По всем вопросам, ddearcj@gmail.com.
This site was made on Tilda — a website builder that helps to create a website without any code
Create a website