Эволюция игрового фреймворка. Клиент 1. Простейшая реализация

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Всякий игровой клиент можно условно разделить на две части:

  • собственно игра, геймплей (Game) и

  • лобби — выбор игры (Lobby/Menu).

На практике это разделение, прежде всего, выражено созданием графики для двух экранов — двух корневых мувиклипа. Назовем их AssetGameScreen и AssetLobbyScreen. В коде, соответственно, создается два основных класса: GameScreen и LobbyScreen.

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

Вкратце план такой. Сначала рассмотрим геймплей на примере самой простой игры, какую только можно придумать (в прошлый раз мы выяснили, что это Dress-Up). Потом перейдем к Lobby и UI вообще. Выделим всё общее между ними в ядро (Core Framework). Ядро будет общим не только для Game и Lobby, но и вообще для любых частей приложения, а также для всех последующих игр.

То, что годится для самой простой игры, годится и для всех остальных. Почему? Потому что всякая сложная игра — это развитие другой, более простой игры. Можно сказать, что простая игра содержится в измененном виде в более сложной. А значит, нам не обязательно реализовывать абсолютно все существующие игры, чтобы доказать, что наш фреймворк и в самом деле универсален. Достаточно будет пройтись по ключевым игровым жанрам и проверить на соответствие нашего кода новым вызовам, которые эти жанры привносят.

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

Так получается несколько базовых игровых фреймворков (Base Game Frameworks), на основе которых создаются жанровые фреймворки (Game Frameworks). Каждый из жанровых фреймворков, собственно, и является самой игрой вместе со всеми возможными ее разновидностями. Разновидности настраиваются в конфигах. Отсюда, готовое приложение может состоять всего из одного класса Main и файла с конфигами. Весь остальной код находится в библиотеках.

Получается следующая иерархия библиотек:

  • Core Framework

  • Base Game Frameworks

  • Game Frameworks

Тут мы будем пока рассматривать только создание Core framework. Про разработку игр на основе Core framework будет когда-нибудь написано продолжение.

Все примеры выполнены на языке Haxe + графическая библиотека OpenFL по описанным выше причинам. Haxe появился как улучшенный ActionScript 3 и является ECMA-подобным языком, так же как TypeScript и JavaScript. Поэтому данное руководство может быть полезно для всех разработчиков HTML5-игр, на каком языке бы они ни писали.

OpenFL же полностью повторяет API популярного ранее Flash. Благодаря этому OpenFL наверняка будет заранее знакома большему числу людей, чем любая другая HTML5-библиотека. Хотя наша задача — так организовать код, чтобы вместо OpenFL можно было использовать любую другую библиотеку. Чтобы смена графической библиотеки никак не затрагивала бы основной массы классов.

Отображение = Графика + Логика

В качестве простейшего примера игры выберем одевалку (Dress-Up). Это даже не игра, а скорее игрушка. В ней нет правил, нет начала и конца, в нее невозможно победить или проиграть. В реальной жизни — это обычная ряженная кукла, которую можно смастерить даже из пучка соломы и тряпки. В мире компьютерных игр она имеет такую же простую структуру. Игра состоит из:

  1. фоновой картинки (изображение куклы),

  2. элементов одежды поверх, частично закрывающие эту картинку, и

  3. кнопок "вперед"-"назад", переключающих соответствующие типы одежды.

Примитивный Dress-Up
Примитивный Dress-Up

Пользователь активно взаимодействует только с кнопками. Фоновая картинка — абсолютно пассивная часть GUI, а одежду можно назвать условно активной составляющей: она изменяется, но не напрямую игроком, а через логику приложения.

Игру как вид искусства, можно определить как интерактивный мультфильм. Сначала мультфильм и только потом интерактивный. Это значит, что на первом месте тут стоит визуализация. Сначала мы видим игровые объекты, а потом начинаем с ними взаимодействовать. За первое отвечает графика (реализована в OpenFL), за второе — логика (пишется на Haxe). Все вместе мы условно назовем пока отображением (View).

Графика

Перед тем как отобразить графику в игре, ее нужно сначала нарисовать, потом экспортировать в подходящий формат и только затем подключить к игре. Для выбранного нами решения (Haxe + OpenFL) графика создается во fla-файлах Adobe Animate. Там же она компилируется (экспортируется) в swf-формат. Файлы swf подключаются к нашему OpenFL проекту (в project.xml), после чего при каждой компиляции проекта все изображения и данные из swf-файлов экспортируются в графические атласы и xml-файлы, подходящие для выбранной платформы. Во время выполнения программы на любой платформе библиотека OpenFL воссоздаст из этих файлов все мувиклипы практически в том же самом виде, как если бы мы запустили обычный Flash-проект.

Вот в чем главная прелесть OpenFL (помимо общего с Flash API). В том, что она полностью снимает с разработчиков заботу о графике. (Точнее, этим занимается библиотека Lime — OpenFL лишь обертка над Lime, предоставляющая Flash API к его объектам.)

Допустим, в fla-файле (открытом в Adobe Animate) у нас есть готовый мувиклип с графикой для игры. Назовем его AssetDresserScreen. И под этим же именем экспортируем его для использования в коде (находим мувиклип в библиотеке, открываем его свойства, ставим галочку возле "Экспортировать для ActionScript"). Далее, чтобы подключить графику в Haxe+OpenFL нужно, во-первых, добавить в project.xml строку вроде:

<library path="assets/dresser.swf" preload="true"/>

Во-вторых, создать экземпляр (instance) одного из экспортируемых мувиклипов. Когда мы запускаем компиляцию проекта, OpenFL генерирует для каждого экспортированного мувиклипа свой собственный класс, который можно использовать прямо в коде. Чтобы эти классы были видны из вашей IDE, нужно добавить еще один путь к исходникам: Export/flash/haxe/_generated (если этого не сделать, компиляция все равно пройдет, но в IDE этот класс будет подсвечиваться как ненайденный).

import openfl.utils.Assets;
import openfl.display.Sprite;

class Main extends Sprite
{
    public function new()
    {
        super();
        // If preload="true". No import needed
        var mc = new AssetDresserScreen();
        addChild(mc);
        // Same, using Assets class:
        var mc2 = Assets.getMovieClip("dresser:AssetDresserScreen");
        mc2.x = 100;
        addChild(mc2);
        // Loading, if preload="false"
        Assets.loadLibrary("dresser").onComplete(function(library)
        {
            var mc3 = new AssetDresserScreen();
            mc3.x = 200;
            addChild(mc3);
        });
    }
}

Помимо автоматически сгенерированных классов также можно использовать вcтроенный в OpenFL менеджер ресурсов — статический класс openfl.utils.Assets. Тут к имени ассета нужно добавлять еще имя библиотеки: "dresser". Это имя получается из имени swf-файла, если из него убрать каталоги и расширение ("assets/dresser.swf" -> "dresser"). Если в project.xml напротив библиотеки не указать preload="true", то библиотеку придется сначала загрузить прежде, чем ее можно будет использовать (Assets.loadLibrary()). В примере выше показаны все три способа создания мувиклипа.

Логика

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

Пусть в AssetDresserScreen будет 3 мувиклипа для одежды (item1/2/3), которую нужно менять (шляпа, рубашка и штаны). Разные варианты одежды распределены по кадрам внутри мувиклипа. Слева и справа от каждого мувиклипа будет по кнопке для переключения одежды. Когда мы задаем имена для элементов внутри экспортируемого мувиклипа, они все становятся свойствами в автоматически генерируемом классе:

class AssetDresserScreen extends openfl.display.MovieClip {
    @:keep public var item2 (default, null):openfl.display.MovieClip;
    @:keep public var nextButton3 (default, null):openfl.display.MovieClip;
    @:keep public var prevButton1 (default, null):openfl.display.MovieClip;
    @:keep public var item3 (default, null):openfl.display.MovieClip;
    @:keep public var nextButton1 (default, null):openfl.display.MovieClip;
    @:keep public var prevButton2 (default, null):openfl.display.MovieClip;
    @:keep public var nextButton2 (default, null):openfl.display.MovieClip;
    @:keep public var item1 (default, null):openfl.display.MovieClip;
    @:keep public var prevButton3 (default, null):openfl.display.MovieClip;
    //...
}

Поэтому к ним можно обращаться напрямую, если используется класс AssetDresserScreen:

class Main extends Sprite
{
    public function new()
    {
        super();
        // If preload="true"
        var mc = new AssetDresserScreen();
        addChild(mc);

        var items = [mc.item1, mc.item2, mc.item3];
        var prevButtons = [mc.prevButton1, mc.prevButton2, mc.prevButton3];
        var nextButtons = [mc.nextButton1, mc.nextButton2, mc.nextButton3];
     }
}

Также от класса AssetDresserScreen можно наследоваться:

class Main extends Sprite
{
    public function new()
    {
        super();
        // If preload="true"
        var mc = new Dresser();
        addChild(mc);
    }
}
class Dresser extends AssetDresserScreen
{
    public function new()
    {
        super();
        var items = [item1, item2, item3];
        var prevButtons = [prevButton1, prevButton2, prevButton3];
        var nextButtons = [nextButton1, nextButton2, nextButton3];
    }
}

Но мы не станем плодить новых сущностей и пока остановимся на Main.

К внутренним объектам мувиклипа можно добраться и более универсальным способом — с помощью метода getChildByName():

class Main extends Sprite
{
    public function new()
    {
        super();
        // If preload="true"
        var mc = new AssetDresserScreen();
        addChild(mc);
        var item1 = mc.getChildByName("item1");
        var item2 = mc.getChildByName("item2");
        var item3 = mc.getChildByName("item3");
        var prevButton1 = mc.getChildByName("prevButton1");
        // and so on...
        var items = [item1, item2, item3];
        var prevButtons = [prevButton1, prevButton2, prevButton3];
        var nextButtons = [nextButton1, nextButton2, nextButton3];
    }
}

Такой способ более общий, потому что он подходит для любых мувиклипов, в том числе и загруженных отдельно. То есть использовать сгенерированные классы теперь не обязательно. Кроме того, такое решение удобнее, ведь мы теперь можем получать объекты по именам-строкам, которые можно определять во время выполнения, а не во время компиляции, как раньше. Таким образом, метод getChildByName() позволяет избавиться от хардкодинга в отношении графики:

class Main extends Sprite
{
    public function new()
    {
        super();
        // View
        // (If preload="true" in project.xml)
         var mc = Assets.getMovieClip("dresser:AssetDresserScreen");
        addChild(mc);
        // Logic
        var items:Array<MovieClip> = [];
        var prevButtons:Array<MovieClip> = [];
        var nextButtons:Array<MovieClip> = [];
        var i = 0;
        while (true)
        {
            var item = cast mc.getChildByName("item" + i);
            var prevButton = cast mc.getChildByName("prevButton" + i);
            var nextButton = cast mc.getChildByName("nextButton" + i);
            if (i > 0 && item == null && prevButton == null && nextButton == null)
            {
                break;
            }
            items.push(item);
            prevButtons.push(prevButton);
            nextButtons.push(nextButton);
            i++;
        }
    }
}

Теперь когда мы будем добавлять дополнительные элементы в swf, код будет их автоматически подхватывать, сколько бы их ни было. При этом никаких изменений внутри кода не потребуется! (Главное, чтобы все числа в имени отличались строго на единицу: ..., nextButton3, nextButton4, nextButton5, ...) Очень удобно. Плюс, нам больше не нужно подключать в IDE класс AssetDresserScreen. Отныне всю графику будем брать в общем виде — как экземпляры класса MovieClip.

Далее. В коде выше можно заметить по три вызова методов getChildByName() и push(). Чтобы избавиться от дублирования кода, проведем небольшой рефакторинг. Вынесем повторяющийся код в отдельную функцию (Extract method refactoring):

class Main extends Sprite
{
    public function new()
    {
        super();
        // View
        // (If preload="true" in project.xml)
         var mc = Assets.getMovieClip("dresser:AssetDresserScreen");
        addChild(mc);
        // Logic
        var items = resolveNamePrefix("item");
        var prevButtons = resolveNamePrefix("prevButton");
        var nextButtons = resolveNamePrefix("nextButton");
        function resolveNamePrefix(namePrefix:String):Array<DisplayObject>
        {
            var i = 0;
            var result = [];
            while (true)
            {
                var object = cast mc.getChildByName(namePrefix + i);
                if (i > 0 && object == null)
                {
                    break;
                }
                result.push(object);
                i++;
            }
            return result;
        }
    }
}

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

class Main extends Sprite
{
    public function new()
    {
        //...
        for (i in 0...items.length)
        {
            var item = items[i];
            item.stop();
            var prevButton = prevButtons[i];
            if (prevButton != null)
            {
                prevButton.buttonMode = true;
                prevButton.addEventListener(MouseEvent.CLICK, function (event:MouseEvent) {
                    item.gotoAndStop(item.currentFrame - 1);
                });
            }
            var nextButton = nextButtons[i];
            if (nextButton != null)
            {
                nextButton.buttonMode = true;
                nextButton.addEventListener(MouseEvent.CLICK, function (event:MouseEvent) {
                   item.gotoAndStop(item.currentFrame + 1);
                });
            }
        }
    }
}

Вот, собственно, и готова простейшая игра-одевалка. Графика, немного несложной логики и никаких пока моделей и контроллеров. Как видно, для игры вполне достаточно только View. Модели и контроллеры понадобятся позже, когда программа станет настолько большой, что в ней черт ногу сломить сможет. Точнее, как концепции они присутствуют и тут, только пока еще в неразличенном виде — модель и контроллер еще плотно слиты в единое целое с отображением. Состояние, которое мы позже будем хранить в модели, тут представлено в виде свойства currentFrame мувиклипов "одежды". А действия, которые должны бы идти через контроллеры, в данном случае пока что не выходят за рамки функций-слушателей, которые меняют состояние-currentFrame с помощью метода gotoAndStop().

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

Подробнее про устранение дублирования кода

В одной из первых версий класса Main мы можем видеть, как одно и то же действие выполняется несколько раз независимо друг от друга (getChildByName(), push()). То есть присутствует явное дублирование кода. Если мы будем использовать не 3 мувиклипа, а 6, то и объем кода удвоится, а так не должно быть. Поэтому мы преобразовали код в цикл.

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

Для повторного использования кода в разных местах он выносится в отдельную функцию (тут resolveNamePrefix()). Недостаток использования функции в том, что на ее выполнение можно влиять только через ее параметры. Внешние переменные хоть и доступны в функции, но они глобальные, а потому одинаковы для разных ее вызовов. Получается, что функция имеет только один контекст. Чтобы добавить к функции собственный контекст и состояние, то есть набор своих собственных переменных, она "оборачивается" в класс.

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

Если смотреть на объекты на физическом уровне или с точки зрения языка ассемблера, то это не что иное, как группа смежных ячеек памяти. И ничего больше. Ссылка на объект — это ссылка на первый байт этой группы. Вся группа разделяется на подгруппы байт, каждая из которых соответствует той или иной переменной-члену (свойству). Переменные различаются по смещению — номеру байта, начиная с первого байта объекта. Количество свойств и длина каждого из них определяет общее количество байт, которое занимает объект в памяти. Методы класса ничем не отличаются от обычных функций. За тем лишь исключением, что первым аргументом в них неявно передается ссылка на объект, к которому они относятся (в Python это сделано явно — self). А обращения к свойствам объекта внутри метода преобразуются компилятором в прибавлении к этому адресу того или иного смещения: +1, +2, +4 и т.д.

Получается, что объекты на физическом уровне — это почти то же, что и массивы. То есть адрес первого байта и фиксированные смещения относительно этого адреса. Единственное отличие то, что все элементы массива одного типа, а потому и все смещения между соседними элементами фиксированы, тогда как в объекте — могут быть разными. Например, свойство булева типа будет занимать 1 байт, а целочисленного — 4. Все остальные особенности ООП, такие как наследование, проверка и приведение типов, виртуальные функции и прочее, реализуются, обычно, просто в виде дополнительного ассемблерного/машинного кода. Все это лишь своего рода средства автоматизации для упрощения жизни программистов.

Из этого следует, что применяем мы классы или не применяем, мы тем самым практически никак не влияем на производительность и объем затрачиваемой памяти. Создать объект — это все равно, что создать все его переменные по отдельности. Код функции также заносится в память только один раз вне зависимости от того, сколько экземпляров данного класса было создано. Поэтому не имеет значения, сколько методов в классе 10 или ни одного. Если вынести функцию из класса и передавать объект первым аргументом, то это все равно, как если бы функция была методом этого класса.

Создание объекта ничем не отличается от создания любой другой скалярной (bool, int, float) переменной или массива — все они или добавляются в стек, если это переменная локальная (это быстро), или делают запрос в операционную систему (ОС) на выделение N-го количества байт (а это уже медленно).

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

Добавим также, что существует возможность создать свойства, которые будут общими для всех экземпляров класса. Они называются статичными и обозначаются ключевым словом static. Чаще всего статичными делают константы, так как они действительно будут для всех объектов одинаковы — по определению. Что касается статичных переменных, то их рекомендуется избегать, так как тем самым мы теряем контроль над ними. Сейчас переменная должна быть статичной, а через какое-то время уже нет, и приходится переписывать все места, где она используется. (Если вам нужны глобальные настройки или состояние, то лучше всего создать для этого отдельный класс и передавать ссылку на его экземпляр во все заинтересованные в нем классы. Такой подход позволяет в случае надобности разделить один глобальный контекст на несколько.)

Если у нас игра состоит не из одной игры-одевалки, а по меньшей мере из меню и других каких-нибудь экранов, то функционал геймплея придется вынести из Main в отдельный класс:

class Main extends Sprite
{
    public function new()
    {
        super();
        // View
        // (If preload="true" in project.xml)
         var mc = Assets.getMovieClip("dresser:AssetDresserScreen");
        addChild(mc);
        // Logic
        new Dresser(mc);
    }
}
class Dresser
{
    // Settings
    public var itemNamePrefix = "item";
    public var prevButtonNamePrefix = "prevButton";
    public var nextButtonNamePrefix = "nextButton";
    // State
    private var mc:MovieClip;
    private var items:Array<MovieClip>;
    private var prevButtons:Array<MovieClip>;
    private var nextButtons:Array<MovieClip>;

    public function new(mc:MovieClip)
    {
        super();
        this.mc = mc;
        items = cast resolveNamePrefix(itemNamePrefix);
        prevButtons = cast resolveNamePrefix(prevButtonNamePrefix);
        nextButtons = cast resolveNamePrefix(nextButtonNamePrefix);
        for (item in items)
        {
            item.stop();
        }
        for (prevButton in prevButtons)
        {
            prevButton.buttonMode = true;
            prevButton.addEventListener(MouseEvent.CLICK, prevButton_clickHandler);
        }
        for (nextButton in nextButtons)
        {
            nextButton.buttonMode = true;
            nextButton.addEventListener(MouseEvent.CLICK, nextButton_clickHandler);
        }
    }
    private function resolveNamePrefix(namePrefix:String):Array<DisplayObject>
    {
        var i = 0;
        var result = [];
        while (true)
        {
            var object = cast mc.getChildByName(namePrefix + i);
            if (i > 0 && object == null)
            {
                break;
            }
            result.push(object);
            i++;
        }
        return result;
    }
    private function prevButton_clickHandler(event:MouseEvent):Void
    {
        var index = prevButtons.indexOf(event.currentTarget);
        var item:MovieClip = items[index];
        item.gotoAndStop(item.currentFrame - 1);
    }
    private function nextButton_clickHandler(event:MouseEvent):Void
    {
        var index = nextButtons.indexOf(event.currentTarget);
        var item:MovieClip = items[index];
        item.gotoAndStop(item.currentFrame + 1);
    }
}
Комментарий

Класс — это способ сгруппировать несколько функций, имеющих общее назначение. В данном случае все 3 функции, не считая конструктора, относятся к геймплею, а потому вынесены в класс Dresser.

Будучи добавленной в класс, функция становится методом. Если несколько методов должны использовать одну и ту же переменную, или данные должны сохраняться между вызовами одного и того же метода, эти переменные также добавляются в класс на положении полноправных членов (свойств). В данном случае mc, items и другие используются в разных методах, поэтому они и вынесены в переменные-члены класса. В каждом экземпляре значения свойств могут отличаться от значений в других экземплярах. Все вместе свойства образуют контекст для своих методов.

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

Переменные класса условно можно разделить на два типа — это настройки и состояние. Физически они реализуются одинаково, разве что настройки обычно делаются публичными (public), чтобы их можно было задавать снаружи, а состояние чаще всего скрывается от посторонних (private — в Haxe этим словом обозначаются защищенные члены, так как приватных в нем нет). Основное же отличие между свойствами-настройками и свойствами-состоянием находится у нас в голове, в том, как мы используем эти переменные.

Настройки задаются извне и в основном при инициализации — то есть до начала работы объекта, а состояние определяется изнутри и меняется все время жизни объекта. Настройки говорят о том, как должен работать объект, а состояние показывает, как он работает, является результатом его работы. Для большей ясности они выделяются в коде специальными комментариями: Settings и State.

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

Исходники

< Назад | Начало | Вперед >

Источник: https://habr.com/ru/post/675488/


Интересные статьи

Интересные статьи

Эту вещь я хотел сделать с детства, но тяжело такое имплементировать, когда у тебя что на ЕС-1022, что на СМ-4 не хватает памяти. Сейчас такие вещи делаются играючи. Итак, засеем бесконечное поле в иг...
Забытое, сложное, изумительное, красивое дерево со звуком ломающихся коленок.Прострелить колени о патрициюРейтинг0Просмотры19Добавить в закладки 0
В эту тему пришлось детально погрузиться во время работы над обеспечением стандартных механизмов верификации устройств для разных мобильных платформ. Задача сводилась к р...
Процесс написания и выполнения программного кода почти всегда сопряжён с необходимостью искать и исправлять ошибки. И в целом процесс отладки приложений выглядит несложно...
Хоть на дворе и август, мы не расслабляемся и продолжаем готовится к новому деловому сезону. Встречайте 3CX v16 Update 3 Alpha! В этом релизе добавлена автоматическая настройка SIP-транков, основ...