Pull to refresh

Comments 21

Я для себя понял, что в геймдеве просто подход а-ля ReactJS не подходит. Конечно прикольно, когда пишешь модель и декларативно от нее описываешь вьюшку, но на практике так получается только на простеньких сайтах.

В играх, обычно, вьюшка изменяется не сразу при изменении вьюшки, а через какую-то анимацию. Более того, нельзя просто запустить анимацию как только изменилось значение. При чём изменений модели с сервера может навалиться сразу и много. Ну вот пример из вашей статьи (кстати, странный, потому что вьюшка вдруг переползла в модель):

public void Subscription(Text text) {
	this.Get(MONEY).SubscribeWithState(text, (x, t) => t.text = x.ToString());
}


Представим для простоты, что это пошаговая казуальная игра, где есть разные источники денег:
— каждые 10 ходов деньгу дает каждый посаженый цветочек
— деньгу можно получить заказав самолет с дропом
— деньга выпадает из полу-боссов

В каждом случае модель будет достаточно простой — начался ход или игрок нажал кнопку и поле обновилося.

Но анимации совершенно разные и поле должно обновиться в совершенно разные моменты, а не сразу вместе с моделью. Начался ход, но каждый цветочек должен заанимировать давание денег. Перед тем как деньги начислились — самолет должен пролететь и кинуть дроп. Босс должен после смерти красиво упасть и только потом зачислятся деньги.

К сожалению, когда всё это стараешься сделать в React-way, то получается куча костылей, которые мешают разработка

Я для себя решил, что в модели есть Команды, а вьюшка описывает, как эти команды должны проигрываться. Пока не закончится проигрывание команды — следующая не начинается. Ну то есть, к примеру, есть команда FlowerGivesMoney. Мы подписываемся во вьюшке на активацию этой команды и не даём ничего другому проигрываться, пока она не закончится:

class FlowerGivesMoneyView : CommandViewHandler<FlowerGivesMoney> {
  @inject FlowersContainer flowersContainer;
  @inject MoneyView moneyView;

  async On (FlowerGivesMoney command) {
    await flowersContainer.getFlower( command.flowerId ).animateGivingMoney();
	await moneyView.animateMoneyValue( command.newMoneyValue );
  }
}


Как результат — у тебя полный контроль над анимациями и видом своей игры. Проблему роста зависимостей можно решить создав дерево команд. К примеру FlowerGivesMoney внутри вызывает команду GiveMoney, как результат — часть, ответственная за анимацию денег выходит в другой класс:

class FlowerGivesMoneyView : CommandViewHandler<FlowerGivesMoney> {
  @inject FlowersContainer flowersContainer;

  async On (FlowerGivesMoney command) {
    await flowersContainer.getFlower( command.flowerId ).animateGivingMoney();
  }
}

class GiveMoneyView : CommandViewHandler<GiveMoney> {
  @inject MoneyView moneyView;

  async On (GiveMoney command) {
	await moneyView.animateMoneyValue( command.newMoneyValue );
  }
}


Есть нюанс, когда некоторые команды нужно анимировать вместе. К примеру, все цветки должны дать денег одновременно, а не по очереди, но это решается довольно просто.

Вообще комманды и сложные конструкции во View-ах будут описываться во второй и третьей части статьи, которые я планирую завтра и послезавтра. Так что тут отвечу кратко. Случай на столько архитипичный, что я пожалуй включу туда в качестве отличного жизненного примера.

Да, подписывание в модели, конечно, только для примера, на самом деле такие строчки находятся, конечно, в View.ConnectModel(), но про вьювы я тут ещё не говорил, поэтому фейковый пример кинул сюда.

У нас в практике тоже был такой случай, когда открываются мистери боксы, и конечно деньги должны поменяться только когда анимация доиграется до конца. Один из наших разработчиков решил упростить себе жизнь, и по команде значение не менять, но серверу говорить, что изменил, а по концу анимации запускать callback, который поправит значения в модели на нужные. Молодец, короче. Две недели он эти мистерибоксы делал, а потом у нас ещё трижды всплывали очень трудно уловимые ошибки, ставшие результатом его деятельности и на их ловлю пришлось истратить ещё три недели по итогу, при том, что времени на «переписать по нормальному» уже, конечно, никто выделить не мог. Из чего ярко следует, как мне кажется, вывод что лучше нужно было с самого начала сделать всё с нормальной реактивностью.

Так вот, моё решение примерно такое. Конечно деньги лежат не в отдельном поле, а являются одним из объектов в словаре inventory, но это сейчас не так важно. У модели есть одна часть, которая проверяется сервером, и на основе которой работает бизнес-логика, и другая, которая существует только на клиенте. Деньги в основной модели начисляются сразу по факту принятия решения, а во второй части в списке «отложенное показывание» создаётся элемент на ту же сумму, который фактом своего появления запускает анимацию, а по окончании анимации запускается команда, которая этот элемент удаляет.
И в реальном поле показывается не просто значение поля, а значение поля за минусом всех клиентских откладываний. В коде это будет примерно так:
public class OpenMisterBox : Command {
    public BoxItemModel item;
    public int slot;
    // Эта часть команды выполняется и на сервере тоже, и проверяется.
    public override void Applay(GameState state) {
        state.inventory[item.revardKey] += item.revardCount;
    }
    // Эта часть команды выполняется только на клиенте.
    public override void Applay(GameState state) {
        var cause = state.NewPersistent<WaitForCommand>();
        cause.key = item.key;
        cause.value = item.value;
        state.ui.delayedInventoryVisualization.Add(cause);
        state.ui.mysteryBoxScreen.animations.Add(new Animation() {cause = item, slot = slot}));
    }
}
public class MysteryBoxView : View {
    /* ... */
    public override void ConnectModel(MysteryBoxScreenModel model, List<Control> c) {
        model.Get(c, MysteryBoxScreenModel.ANIMATIONS)
            .Control(c, 
                onAdd = item => animationFactory(item, OnComleteOrAbort => {
                    AsincQueue(new RemoveAnimation() {cause = item.cause, animation = item}) }),
                onRemove = null
            )
    }
}

public class InventoryView : View<InventoryItem> {
    public Text text;
    public override void ConnectModel(InventoryItem model, List<Control> c) {
        model.GameState.ui.Get(c, UIModel.DELAYED_INVENTORY_VISUALIZATION).
            .Where(c, item => item.key == model.key)
            .Expression(c, onChange = (IList<InventoryItem> list) => {
                int sum = 0;
                for (int i = 0; i < list.Count; i++)
                    sum += list[i].value;
                return sum;
            }, onAdd = null, onRemove = null ) // Чисто ради показать сигнатуру метода
            .Join (c, model.GameState.Get(GameState.INVENTORY).ItemByKey(model.key))
            .Expression(c, (delay, count) => count - delay)
            .SetText(c, text);
        // Здесь я написал полный код, но в реальности это операция типовая, поэтому для неё, конечно же, существует функция обёртка, которая дёргается в проекте во всех случаях, выглядит её вызов вот так:
        model.inventory.CreateVisibleInventoryItemCount(c, model.key).SetText(c, text);
    }
}
public class RemoveDelayedInventoryVisualization : Command {
    public DelayCauseModel cause;
    public override void Applay(GameState state) {
        state.ui.delayedInventoryVisualization.Remove(cause);
        state.DestroyPersistent(cause);
    }
}
public class RemoveAnimation : RemoveDelayedInventoryVisualization {
    public Animation animation
    public override void Applay(GameState state) {
        base.Applay(state);
        state.ui.mysteryBoxScreen.animations.Remove(animation);
    }
}

Что мы имеем в итоге? Есть два View, в одном из них играется некая анимация, окончания которой ждёт отображение денег в совсем другом вьюве, понятия не имеющем кто и зачем хочет чтобы показывалось другое значение. Всё реактивненько. В любой момент можно загрузить в игру полное состояние GameState и оно начнёт играться ровно с того места, на котором мы остановились, в том числе и анимация запустится. Правда запустится с начала, потому что мы этап анимации не сторим, но если очень нужно, сторить можем даже его.

Эпик фейл!!! Только что обнаружил, что из-за ошибки в форматировании, (неэкранированной треугольной скобки) половина статьи не показывалась! Афигеть вообще…
Не очень понял Ваш подход. Вы описываете случай реал тайма (игровой цикл) или рассматриваете социалки (запрос-ответ)?

«Но анимации совершенно разные и поле должно обновиться в совершенно разные моменты, а не сразу вместе с моделью.»

Если рассматривать социалки (запрос — ответ) — тут обычно делается отдельный запрос на сбор возобновляемого ресурса / побежденного босса / взятии груза. Если заказан самолет с деньгами, деньги лучше забирать отдельным запросом, записывая в модель (на клиенте и сервере) факт того, что груз заказан (который фиксируется в первом запросе). За убийство босса тоже лучше начислять награду отдельным запросом на взятие награды или в запросе на ластхит.

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

«Босс должен после смерти красиво упасть и только потом зачислятся деньги.»

Награда в моделях начисляется сразу, клиент же может предсказывать сервер (используя тот же сид рандома к примеру), либо ждать награду от сервера (если логику начисления награды нужно скрыть от пользователя) — это не важно, главное, чтобы исполнение команды было упорядоченным и давало одинаковые результаты на клиенте и сервере.
Первый вариант FlowerGivesMoneyView по сути корутина Unity — произошла команда, она изменила модель, далее ее поймала вьюшка и она описывает логику отрисовки (вложенные корутины).
Клиент и сервер синхронны, все чисто. Как ведет себя второй вариант — у нас появляется две команды и вторая команда ждет первую? (которая нужна лишь для анимации
и не изменяет модель, чтобы потом запустить вторую с начислением денег?) Потом каждая команда запускает свой запрос, или это только клиентская штука?

Заранее извиняюсь если неверно все понял.
Команды и куски дерева делятся на существующие на сервере и клиенте и на существующие только на клиенте и не проверяющиеся. В первую часть дерева деньги зачисляются сразу. Во второй части модели, невидимой серверу, появляется пометка «часть суммы пока не показывать». По окончании анимации будет создана и выполнена команда, которая на клиентской части эту пометку снимет. На сервер на проверку эта команда не отправляется. В поле показывается не значение переменной, а значение минус все пометки, которых, как правильно замечено, может быть много разных.

Именно такое разделение на две команды делается потому, что если произойдёт перезагрузка между первой и второй командами, не выйдет так что деньги не начислились. В серверной части модели всё уже находится в состоянии после всех отложенных клиентских команд.

Смысл этого как раз в том, чтобы не хранить внутри View никакой скрытой информации, которой не было бы видно на отладочном геймстейте. В том числе и в виде запущенных корутин.
Смысл этого как раз в том, чтобы не хранить внутри View никакой скрытой информации, которой не было бы видно на отладочном геймстейте. В том числе и в виде запущенных корутин.

Я правильно понял, что у вас обновления модели на клиенте зависят от поведения вьюшки? То есть, получив с сервера информацию вы её не заносите в модель сразу, а только тогда, когда это будет нужно View?

Или у вас на клиенте две модели — одна повторяет серверную, а другая служит для отображения вьюшки? Но тогда зачем первая?

А как вы поступаете, если это не клиент-серверная игра, а просто десктопная?
По ходу нет, вы не правильно поняли.
У меня моделью называются обе части модели, и та, которая нужна только для бизнес-логики, и та, которая нужна только для UI. Более того, у меня может быть объект, у которого одно поле серверу видно, а другое рядом с ним интересует только view-ы. То есть на самом деле это одно большое дерево, только сервер его не всё видит.

А дальше команды затрагивающие бизнес-логику, выполняются когда пользователь сделал активное действие, а другие команды, затрагивающие только View могут быть запущены и когда во View что-нибудь произойдёт, анимации, например, доиграются. Серверу от этого не жарко и ни холодно, ему эти команды не отправляются. Подробнее смотрите в следующей статье.
Ну то есть да, вы, в принципе, правильно поняли.
Не очень понял Ваш подход…
Поля моделей обновляются сразу, вьюшка же может приберечь историю этих изменений

Вот об этом я и говорю. Обычно реактивное изменение данных описывается в статьях как:
— изменилась модель => изменилась вьюшка

Если с сервера пришло новое значение — оно записывается в модель и сразу отображается на экране. Это вполне подходит для веба, где нету сложных переходов, но в геймдеве всё не так просто.

Как ведет себя второй вариант — у нас появляется две команды и вторая команда ждет первую? (которая нужна лишь для анимации
и не изменяет модель, чтобы потом запустить вторую с начислением денег?) Потом каждая команда запускает свой запрос, или это только клиентская штука?

Я описывал лишь клиент. У нас есть модель на клиенте. В этой модели выполняются две команды одна за другой, без задержек.

Вьюшка получает все выполненные команды и ставит их в очередь и выполняет анимации привязанные к этим командам по очереди. Пока не закончится анимация для первой команды — не начнется анимация для второй.

Клиент и сервер синхронны, все чисто

Как результат в моём подходе модель на клиенте и сервер синхронны, а вот вьюшка у меня меняется постепенно.

Вот смотрите. Представьте, что у нас карточная игра вроде хартстоуна. Карта атакует противника, у противника срабатывает абилка и тот даёт ресурсы своему игроку. Сервер присылает нам список:
[
  { command: 'attack', source: 42, target: 35 },
  { command: 'deal_damage', target: 35, value: 2 },
  { command: 'activate_ability', source: 35 },
  { command: 'give_money', targetPlayer: 1, newValue: 20 }
]


Если подходить просто — мы синхронно меняем модель и все подписанные поля синхронно меняются. Как только с сервера пришли эти данные — сработал ивент об изменении количество ресурсов у игрока 1 и вьюшка реактивно перерисовалась.

Ну или не применять эти события к модели пока не закончится анимация, но тогда модель становится зависимой от желаний вьюшки, что тоже мне не нравится.
Понял Ваш подход, спасибо. Второй способ (с отдельными командами) выглядит интересней — можно переиспользовать команды; легко компоновать; сервер также отображает эти модельные команды зеркально у себя, все синхронно; для каждой команды вьюшка также описывает свою логику и отдельно ей управляет.
Разве что много команд будет, но это не плохо.

«Сервер присылает нам список:»

По опыту разработки социалок и реал тайма не встречал, чтобы сервер присылал игровые команды. Обычно клиент шлет команды, связанные с логикой, сервер шлет реплику и может слать управляющие команды по типу «у тебя плохое соединение, перезапустись». Как в quake 3 в общем.
Если у Вас клиент выполняется синхронно с сервером, для чего получать от него команды? Клиент сам может спавнить команды и управлять этим (кроме каких-то команд, требующих получения данных от сервера, которые нельзя предсказывать — сокрытие определенной логики — открытие сундучков например, но и там клиент обычно получает данные, не команды).
Собственно подробнее про команды в следующей статье, которую я опубликовал сегодня: habr.com/post/435704
По опыту разработки социалок и реал тайма не встречал, чтобы сервер присылал игровые команды

Представьте, что:
1. Логика сложная и нету смысла её дублировать в двух местах
2. Логика зависит от неизвестных клиенту переменных. К примеру «Когда берете карту если это трефы — возьмите ещё одну». Пока не получим информацию о первой карте — не знаем инфу о результате. Ну или как в МТГ — на каждое действие игрок может сыграть контрдействие.

Как результат — на сервер мы посылаем намерение (взять карту, атаковать, ходить). Сервер считает, что из этого вышло и посылает назад список действий/команд, которые произошли в модели.

открытие сундучков например, но и там клиент обычно получает данные, не команды

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

пс. Вот тут я описывал, как оно все работает: habr.com/post/322258
В таком решении вы мешаете чисто клиентскую декоративную логику — анимацию — с серверной бизнес-логикой — зачислением денег. А это может привести к проблемам.
Например, если вы командой запускаете катсцену при убийстве босса, и в конце её зачисляете награду за убийство. А другой человек реализует функциональность пропуска катсцен — отменой команды. Или же игрок босса добил, а анимацию решил не смотреть и перезагрузил страничку, скажем. И в результате игрок не получает награду.

Не знаю, насколько вероятен такой пример в реальности, но предложение автора — отдельные события «обновить деньги на сервере» и «показать обновление денег клиенту» мне нравится больше.
В таком решении вы мешаете чисто клиентскую декоративную логику — анимацию — с серверной бизнес-логикой — зачислением денег

Нет. Команды у меня всегда выполняются и выполняются в модели. Более того, они выполняются мгновенно, в модели нету такого понятия как ожидания конца анимации.

Обратите внимания — в моем примере есть класс GiveMoney (это команда модели) и GiveMoneyView (это как данная команда должна отобразиться во вьюшке).

А другой человек реализует функциональность пропуска катсцен — отменой команды

Это невозможно. На момент запуска катсцен команда уже выполнилась, её нельзя отменить. Можно только повлиять на вьюшку.

Или же игрок босса добил, а анимацию решил не смотреть и перезагрузил страничку, скажем
Модель уже изменена. Как только был нанесен последний удар — в тот же момент босу нанеслись повреждения, он зачислен мертвым, игроку упали деньги, матч назван завершенным. Просто вьюшка на это реагирует не реактивно. Она получила список команд (DealDamane, Death, GiveMoney, EndGame) и начинает по очереди их анимировать. Сначала первую, когда закончит — вторую и так далее.

отдельные события «обновить деньги на сервере» и «показать обновление денег клиенту» мне нравится больше

Мне не нравится в этом решении: «как только сервер прислал информацию о новом количестве денег — поле с деньгами обновляется автоматически». Вот тут, смотрите:

public class PlayerModel : Model {
    public ReactiveProperty<int> money = new ReactiveProperty<int>();
    public ReactiveProperty<InventoryModel> inventory = new ReactiveProperty<InventoryModel>();

    /* Using */
    public void SomeTestChanges() {
        money.Value = 10;
        inventory.Value.capacity.Value++;
    }
    public void Subscription(Text text) {
        money.SubscribeWithState(text, (x, t) => t.text = x.ToString());
    }
}


Как только изменяется поле money модели — сразу же изменяется вьюшка и игрок видит новое значение. Но перед этим должны были проиграться анимации нанесения дамага, смерти боса и выпадающих монеток и лишь потом во вьюшке обновиться поле с монетками.

Я понимаю, что пример утрированный, но во всех статьях о реактивном гейм-девелопменте я вижу именно такие утрированные примеры. Возможно, это как-то решается с РХ, но я таких статей не видел.
Так я в комментариях как раз и привёл не вырожденный случай подписки в функции InventoryView.ConnectModel(), он там аж 11 строк занимает, и агрегирует информацию из массива сложных моделей и отдельно лежащего поля чтобы показать результирующую.
В вашем примере меня смущают 2 вещи:

Слишком много вьюшки в модели. К примеру, тут в модели лежат какие-то анимации каких-то контролов:
model.Get(c, MysteryBoxScreenModel.ANIMATIONS)


А тут вообще под UI отдельный объект выделили.
model.GameState.ui


Но это не еще не главное. Больше меня смущает сложность идеи. Вроде текущая сума записывается в поле с сумой, но у нас ведь реактивность, а значит вьюшка должна была бы поменяться, а значит нам нельзя показывать это поле. Потому мы, конечно, в это поле записываем (но зачем?), а потом показываем изображение от этого поля плюс каких-то странных отложенных анимаций. То есть значение того поля, которое нам необходимо показать — это какой-то список непонятных вещей. И выглядит как реактивность ради реактивности.

Во всём вашем комментарии я не могу понять одного — неужели это и правда просто поддерживать? Потому что мне кажется, что сложно. Вот этот момент с откладыванием мне кажется страшными костылями, которые вообще не вписываются в идею реактивности.
И вы чертовски правы. На самом деле у меня часто бывает так, что я сначала делаю какую-то конструкцию составным контролом до тех пор пока её можно сделать легко, грубо говоря, пока я могу собрать её из стандартных контролов, которые я могу написать «с листа» без отладки и не ошибиться. А если конструкция усложняется на столько, что у меня возникают такие сомнения, как у вас сейчас я переношу все эти вычисления в модель и сложных трудно отлаживающихся контролов становится меньше, а моделей — больше, но они отслеживаются как раз очень просто. Так часто бывает, потому что геймдизайнер часто хочет усложнить в самых неожиданных местах, никогда не угадаешь в каких. Пример такой ситуации есть во второй статье habr.com/post/435704 в виде: match.CalculateAssignments(); Когда-то количество бабла для закупок определялось просто — количество бабла на одну сторону, делённое на количество игроков на этой стороне. Очень просто сделать контрол в три строчки и не париться. Но потом выяснилось, что закупка не считается успешной если закупленно меньше, чем на половину суммы, а с другой стороны можно потратить больше своей доли, на 10% если остальные игроки свои доли пока не израсходовали. В таком варианте отлаживать это на многоуровневых реактивных конструкциях было уже явно не подарок, и это превратилось в правила над моделью, а количество доступного игроку бабла стало не вычисляемым значением, а полем в модели, которое пересчитывалось этой вот функцией.
Придумал новое мероприятие по этому же коду — кодревьюв во время стрима с объяснением что в коде конкретно написано и почему. Показывать буду как раз ту самую реактивность, которая описана в третьей части статьи и немного здесь в комментариях.
habr.com/ru/post/480668
Думаю, название статьи не совсем соответствует содержанию. Описанные подходы не являются эксклюзивными для мобильных игр. В частности, мы в компании используем крайне похожие подходы в кросплатформенной (ПК / планшет) разработке для бизнес-приложений.
Почему именно игры яснее видно во второй части про команды: habr.com/post/435704
В отличии от бизнес-приложений типичная игра почти полностью детерминирована клиентской информацией, поэтому предиктит почти все действия сервера, кроме того возникают типичные для игр, но не характерные для бизнес-приложений проблемы, что много анимаций, которые надо асинхронно контролировать. Наконец именно для игр характерна ситуация отладки когда цена ошибки невелика, ДАУ очень велико, но при этом информация, известная программисту обычно явно недостаточна, и логов мало, и приложение делает очень много что не сообщая об этом серваку.

Но так то да, комплекс проблем характерен не только для игр.
Sign up to leave a comment.

Articles