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

https://habr.com/ru/post/695428/

Итак, с чем мы закончили в прошлый раз? У нас была простенькая демка, которая работала как на ПК, так и на коммуникаторах. Можно было запустить игру, пострелять врагов и... всё. Не так уж и интересно, верно?

Вектор работ

Теперь нам нужно довести демку до полноценной, пусть и неболшой игры. Само собой мало кто будет откапывать свой старый коммуникатор из кладовки, только для того, чтобы запустить игрушку из статьи. Да и мы ведь хотим, чтобы в нашу игру поиграли люди? Поэтому первая цель - порт на Android. Затем, что нам нужно ещё? Ага - меню! В менюшке у нас должно быть всё просто - игра и выход, а ещё мы должны видеть всю важную статистику(на момент написания статьи - рекорд по счёту). Теперь же, мы подошли к следующему пункту - сохранение прогресса. Ой, а интерфейс слишком мелкий на моём самсунге с 2K дисплеем! Для этого нам нужен масштабируемый UI. Так начнём же с порта!

Порт на Android

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

Все мы помним, что у нас есть простое ядро игры, которое ни в чём нас не ограничивает, где можно пока не реализованные модули заменить пустыми заглушками и портировать не сидя 24/7 в дебаггере? Это хорошо. В этом случае у нас уже добавляется поддержка 3-его GAPI: OpenGL ES. Поскольку я обещал, что поиграть можно будет на чём угодно, берём минимальный таргет Xamarin - Android 2.3, а версию ES - 1.1. При желании перенести игру с FFP на шейдеры не составит вообще никаких проблем. Кроме того, Metal я пока не щупал, поэтому порт на iOS будет работать на GLES - останется портировать лишь Window и звук. Контекст создаёт сама платформа.

Что нам нужно портировать?

  1. Рендерер

  2. Звук

  3. Ввод

  4. Отрисовка шрифтов

Кроме того, я не отлаживаю никогда на эмуляторе - я всегда запускаю игру на реальном девайсе, а их у меня, тьфу тьфу тьфу, много(и вам читателям, за это спасибо. Где бы я сейчас откопал Moto Milestone?). Девайсы разных конфигураций - x86 смартфон, смартфоны с Adreno 200, 205, 305, PowerVR SGX531/535, Mali 400 в различных конфигурациях, MSM8255, MSM7225, MT6572, MT6575, Atom Z2580. Весь этот зоопарк можно и нужно протестировать - ведь если пойдёт на них и не допущено критичных косяков по совместимости на Android 10/11+, то игра пойдёт вообще везде. Для тестов я использую недавно купленный Sony Ericsson Xperia U - на нем и андроид достаточно старый(4.0.4) и отладка очень быстрая - у меня на самсунге с 7 андроидом деплой секунд 20, на xperia u - секунд 5.

Как вы помните, главная абстракция от платформы у нас это класс Window - он управляет "вычислением" физических путей к ассетам, он содержит сведения о платформе(мобильная ли она или нет, название, язык), он управляет окном, и от него можно получить размеры экрана. В случае Android, Window - это просто прослойка между главным Activity и самой игрой. MainActivity шлёт события(например, нажатие хардварной кнопки или тап по экрану) в Window, а тот выступает в роли диспетчера, переправляя это всё в подсистему ввода и.т.п. В случае Android, циклом отрисовки и созданием контекста занимается GLSurfaceView, а нам остаётся только портировать сам рендерер. Вот так выглядит MainActivity:

[Activity(Label = "@string/app_name", ScreenOrientation = Android.Content.PM.ScreenOrientation.Landscape, Theme = "@android:style/Theme.NoTitleBar.Fullscreen", MainLauncher = true)]
    public class MainActivity : Activity, GLSurfaceView.IRenderer
    {
        public static MainActivity Current
        {
            get;
            private set;
        }

        private Engine engine;
        private GLSurfaceView surfView;

        public void OnDrawFrame(IGL10 gl)
        {
            engine.DrawFrame();
        }

        public void OnSurfaceChanged(IGL10 gl, int width, int height)
        {

        }

        public void OnSurfaceCreated(IGL10 gl, Javax.Microedition.Khronos.Egl.EGLConfig config)
        {
            Engine.Init(surfView.Width, surfView.Height);

            engine = Engine.Current;
            engine.Window.GLSurface = surfView;
        }

        private bool ThrowCriticalError(string message)
        {
            AlertDialog.Builder builder = new AlertDialog.Builder(this);
            builder.SetTitle("Critical error");
            builder.SetMessage(message);
            builder.SetNegativeButton("OK", (object sender, Android.Content.DialogClickEventArgs args) => { Finish(); });
            builder.Show();

            return false;
        }

        private bool CheckStorageStatus()
        {
            string state = Android.OS.Environment.ExternalStorageState;
            string path = Android.OS.Environment.ExternalStorageDirectory.AbsolutePath;

            if (state != Android.OS.Environment.MediaMounted && state != Android.OS.Environment.MediaMountedReadOnly)
                return ThrowCriticalError("An external sdcard is required to play this game.");

            if (!System.IO.File.Exists(path + WMGame3D.Window.DataPath + ".data"))
                return ThrowCriticalError("Game assets not found. Please copy them from game archive to " + path + WMGame3D.Window.DataPath);

            return true;
        }

        protected override void OnCreate(Bundle savedInstanceState)
        {
            base.OnCreate(savedInstanceState);

            if (CheckStorageStatus())
            {
                Current = this;
                surfView = new GLSurfaceView(ApplicationContext);
                surfView.SetRenderer(this);
                SetContentView(surfView);
            }
        }

        public override bool OnKeyUp([GeneratedEnum] Keycode keyCode, KeyEvent e)
        {
            engine.Window.KeyUp(keyCode, e.KeyCode);

            return base.OnKeyUp(keyCode, e);
        }

        public override bool OnKeyDown([GeneratedEnum] Keycode keyCode, KeyEvent e)
        {
            engine.Window.KeyDown(null, e.KeyCode);

            return base.OnKeyDown(keyCode, e);
        }

        public override bool OnTouchEvent(MotionEvent e)
        {
            if (engine != null)
            {
                if (e.Action == MotionEventActions.Move)
                    engine.Window.TouchMove(this, e.GetX(), e.GetY(), true);

                if (e.Action == MotionEventActions.Up)
                    engine.Window.TouchUp(null);
            }

            return base.OnTouchEvent(e);
        }
    }

В классе Window добавился PlatformInfo, который как я уже говорил, содержит в себе флаги о текущей платформе, и пути к различным директориям(например, где хранятся локальные данные приложения:

public struct PlatformInfo
    {
        public string Name;
        public bool IsMobile;
        public string AppPath;
        public string DataPath;
    }

Поскольку на Android бывают ещё и игровые приставки, и смарт телевизоры, где тачскрина нет - у нас всё так же остаётся система биндов кнопок. т.е люди как с планшетами-трансформерами, так и с геймпадами смогу поиграть в игру без проблем.

Что касается механизма загрузки ассетов, то с андроидовской концепцией ассетов всё криво - Xamarin не может просканировать папку и добавить все ассеты оттуда, это муторно и неудобно. Поэтому на данный момент, все ассеты хранятся на /sdcard/GameData/WMGame3D/(sdcard в большинстве случаев - внутренняя память). Это и называется кэшем игры. Я видел ещё один вариант - распаковка ассетов на лету(Galaxy On Fire 2) в папку игры, но такой вариант мне не по душе. Поэтому если я добавил ещё одну модельку корабля - я не шерстю Visual Studio, а просто по MTP скидываю изменившиеся файлы. Просто и удобно, но доступ к таким ассетам из версий 4.4+ только read-only. Писать можно только в свою папку(/data/data/<package>/files), там же у нас будут сохранения.

А ещё Android'овские ассеты не поддерживают seek - это тоже минус, приходится сразу захапывать весь файл в память, и возвращать уже MemoryStream

Касательно рендерера - ничего особо не поменялось. Всё точно так же, только теперь меши рисует GLES. Порт занял около часа. Текстуры грузятся с помощью нативного Bitmap, и LockPixels.

Звук я реализовал на SoundPool. В OpenTK не оказалось обертки над OpenSL(да и я им не пользовался никогда, зато пользовался OpenAL - который валился в SIGSEGV на андроиде). В целом работает нормально, но SoundPool на 2.3 не поддерживает загрузку звуков из Stream. Никакого 3D звука нет и возможности узнать, проигрывается ли ещё файл тоже, но у нас фактически весь звук - проиграть музыку и эффекты, поэтому нам хватит.

Отрисовка шрифтов у нас нативная - во первых это поддержка юникода и не- моноширинных шрифтов из коробки, во вторых нативные шрифты сглажены. Для этого у нас используется Bitmap размерами с экран и Canvas, который рисует шрифты. Затем в конце кадра итоговый битмап заливается в текстуру, а текстура рисуется как квад на весь экран. Это не просаживает FPS, насколько я знаю - на стороне Android'а рендер шрифтов хардварный, поэтому даже большое количество текста должно перевариваться нормально. Если кто-то будет использовать нативный рендерер шрифтов Android, то не забываем, что координата Y начинается с нижней части текста. Не забываем получить метрики шрифта и вычитать Top из координаты текста, иначе текст "уползёт" вверх.

Есть вариант рисовать текст "оверлеем" уже на уровне оконной системы. Android такие трюки умеет, а вот Windows - нет, GDI очень тормозной в этом плане. Но на Windows есть удобный DirectWrite.

Есть ещё концепция "запекания" отрисованного текста в текстуру фиксированного размера(подобным образом работал TextRenderer в старых версиях Unity).

Лучший вариант это конечно же запекание глифов в атласы, но тогда придётся думать над эффективным батчингом, переключением страниц с глифами, и думать над размерами(либо использовать distance field шрифты).

Уже можно поиграть
Уже можно поиграть

Полная реализация:

public sealed class FontManager
    {
        private Bitmap fontBitmap;
        private Canvas canvas;
        private Material material;

        internal Paint paint;

        public FontManager()
        {
            fontBitmap = Bitmap.CreateBitmap(Engine.Current.Window.ViewportWidth, Engine.Current.Window.ViewportHeight, Bitmap.Config.Argb8888);
            canvas = new Canvas(fontBitmap);

            material = new Material();
            material.Texture = new Texture2D();

            paint = new Paint();
        }

        internal void Begin()
        {
            fontBitmap.EraseColor(Color.Transparent.ToArgb());
        }

        internal void DrawString(NativeFont font, string str, int x, int y, int r, int g, int b, int a)
        {
            paint.SetTypeface(font.typeface);
            paint.Color = new Color(r, g, b, a);
            paint.TextSize = font.Size;
            canvas.DrawText(str, (float)x, (float)y - paint.GetFontMetrics().Top, paint);
        }

        internal void End()
        {
            IntPtr ptr = fontBitmap.LockPixels();
            material.Texture.Upload(ptr, fontBitmap.Width, fontBitmap.Height);
            fontBitmap.UnlockPixels();
            
            Engine.Current.Graphics.DrawSprite(material, Vector3.Zero, new Vector3(fontBitmap.Width, fontBitmap.Height, 0));
        }
    }

    public sealed class NativeFont
    {
        internal Typeface typeface;
        public float Size;

        public NativeFont(string typeFace, float size)
        {
            Size = size;
            typeface = Typeface.Create(typeFace, TypefaceStyle.Normal);
        }

        public int MeasureString(string str)
        {
            // A bit hacky
            Engine.Current.FontManager.paint.SetTypeface(typeface);
            Engine.Current.FontManager.paint.TextSize = Size;

            return (int)Engine.Current.FontManager.paint.MeasureText(str);
        }
    }

Не обращаем внимание на MeasureString.

Игра на десктопе
Игра на десктопе

Игра запускается и работает! Отлично.

Масштабируемый UI

Интерфейс у нас не зависит от разрешения экрана - пусть и сам по себе он примитивный. Это достигается за счёт двух очень простых концепций. Здесь нет концепций Align'ов, Docking'ов, каких либо сеток или других концепций для реализации UI. Всё по спартански просто:

Размер шрифта - относительное значение того, какой шрифт смотрелся бы лучше всего на разрешении 640x480. Это очень старая концепция, я её видел ещё в GTA San Andreas. Однако всё равно размер шрифта нужно ограничивать в определённых размерах:

public static float GetAbsFontSize(float rel)
        {
            float ratio = (float)Engine.Current.Window.ViewportWidth / 640;

            return Mathf.Clamp(rel * ratio, MinFontSize, MaxFontSize);
        }

Есть ещё одна интересная концепция: прописывать заранее весь layout и размеры шрифтов в зависимости от разрешения. Например QVGA, WVGA, VGA, SVGA, HD, FHD, UHD. Если разрешение где-то посерединке, то брать от ближайшего к нему.

Что касается позиционирования UI элементов, здесь можно использовать относительную(процентную относительно размера дисплея) систему координат т.е 0.0-1.0. Тут тоже всё очень просто, и классически:

public static Rectf RelativeToAbs(Rectf rel)
        {
            Vector3 pos = new Vector3(rel.X * Engine.Current.Window.ViewportWidth, rel.Y * Engine.Current.Window.ViewportHeight, 0);
            Vector3 size = new Vector3(rel.W * Engine.Current.Window.ViewportWidth, rel.H * Engine.Current.Window.ViewportHeight, 1);

            return new Rectf(pos.X, pos.Y, size.X, size.Y);
        }

По итогу UI масштабировался нормально, но на мобилках listmenu(т.е менюшки) выглядели мелковато, поэтому если мы играем на мобилке - принудительно размер шрифтов увеличивается в два раза.

Все меню в игре сделаны на ListMenu - который поддерживает как ввод с кнопок, так и с тачскрина. Удобно и кроссплатформенно!

Что касается главного меню, MainMenu - отдельный стейт игры. Он рисует фон, само главное меню и управляет подменюшками т.е SubMenu. Просто и компактно.

Механизм сейвов

Нам ведь нужно где-то хранить, сколько очков набрал пользователь, сколько врагов перебил, как его звать - верно? Для этого и нужен механизм сохранений.

В нашей игре он будет до безобразия примитивным - простой бинарный файл, в который записана статистика, очки, деньги, опыт и.т.п + номер версии. Однако! Это не самый лучший подход, поскольку накладывает огромное количество ограничений:

  • Во первых если вы пишите игру, под что-то отличное от little-endian, вам придётся в рантайме менять местами байты, иначе получите билиберду. С текстовыми форматами таких проблем нет.

  • Во вторых, если вы на этапе разработки решили ограничить максимальный уровень до размера байта(255), а через 10 версий решили, что пользователь сможет прокачаться до уровня 1024 - вам придётся либо велосипедить второй байт уровня где-то посерединке, либо менять формат сейва - делая его несовместимым с прошлыми версиями

  • Если у вас кардинально меняется файл сохранения(например, вы сохраняете сущность. У вас есть метаданные и захардкоженные свойства а-ля позиция. И вы захотели переместить это свойство в метаданные) - вам придётся городить всякие Version15Converter, Version16Convert и прочие костыли, либо заставлять игроков начинать новую игру. Перспектива так себе, верно?

Плюсы:

  • Малый размер файла. Это очень актуально для игр с сложными сценами, и где сохраняется фактически полная копия уровня со всем состоянием.

  • Быстрая загрузка. В большинстве простых игр, сейв можно тупо "отразить" в память. Xml на 15 мегабайт будет парсится куда дольше.

В нашей игре подход простой: статистика(пока не используется, но зарезервирована для будущих версий) в виде структуры и сами данные

public struct Statistics
        {
            public int TotalGames;
            public int TotalMoneyEarned;
            public int TotalMissions;
            public bool UsedCheats;
        }

public int Level;
        public int Experience;
        public int Money;
        public int HighScore;
        public Perks Perks;

Для сохранения/загрузки используется BinaryReader/BinaryWriter. Использовать BinarySerializer я не стал для пущей наглядности, как это работает.

Stream stream = File.OpenRead(GetSaveFilePath());
            BinaryReader reader = new BinaryReader(stream);
            int version = reader.ReadInt16();

            if(version != Header)
            {
                Engine.Current.Log("Incorrect save file version, current is {0}, while this one is {1}", Header, version);

                StartNewGame();
                return;
            }

            Stats.TotalGames = reader.ReadInt16();
            Stats.TotalMissions = reader.ReadInt16();
            Stats.TotalMoneyEarned = reader.ReadInt16();
            Stats.UsedCheats = reader.ReadBoolean();
            Level = reader.ReadInt16();
            Experience = reader.ReadInt16();
            Money = reader.ReadInt16();
            HighScore = reader.ReadInt32();
            Perks = (Perks)reader.ReadInt16();

Выглядит просто и удобно. Если полей в какой-то момент станет больше - то проще завести KeyValue список, в котором хранить всё что надо, или использовать сериализацию.

Вывод: для достаточно примитивной игры - это нормальное решение. Для чего-то более комплексного лучше использовать сериализацию(благо в .net она очень мощная из коробки, в т.ч есть адаптеры для бинарной сериализации).

Менюшка

Главное меню - отдельный стейт игры, который разделен на различные подменю(ISubMenu). Сама менюшка - ListMenu, как уже отмечено выше - поддерживает ввод, как с пальца/мыши, так и с геймпада/клавиатуры. В главном меню, как стейте, есть "подменю" с основными пунктами, и потенциальные подменю для настроек/магазина(их я не успел реализовать).

public sealed class MainMenu
    {
        private Background background;
        private ISubMenu subMenu;
        private NativeFont rendererCopy;
        private NativeFont renderer;

        public SmoothAnimator Animator;

        private MainSubMenu mainSub;

        public MainMenu()
        {
            background = new Background();
            Animator = new SmoothAnimator(new UISwipeIn(new Vector3(-120, 0, 0), new Vector3(20, 0, 0)));

            rendererCopy = new NativeFont("Arial", 8);
            renderer = new NativeFont("Arial", 12);
        }

        public void PostInitialize()
        {
            mainSub = new MainSubMenu();
            SetSubMenu(mainSub);
        }
        
        public void SetSubMenu(ISubMenu menu)
        {
            if (menu == null)
                subMenu = mainSub;
            else
                subMenu = menu;
        }

        public void Update()
        {
            Animator.Update();
            subMenu.Update();
        }
        
        public void Draw()
        {
            background.Draw();
        }

        public void DrawUI()
        {
            GUI.DrawTextRelative(renderer, "Личный рекорд: " + Game.Current.Save.HighScore, ListMenu.Normal, 0.01f, 0.9f);

            subMenu.Draw();
            
            renderer.DrawString("(C)2022 Bogdan Nikolaev aka monobogdan", 5, Engine.Current.Window.ViewportHeight - (int)renderer.Size - 5, 255, 255, 255, 255);
        }
    }

Где подменюхи(анонимные методы не используются в угоду поддержки древних версий С# - игра под WM затачивалась) :

public interface ISubMenu
    {
        void Update();
        void Draw();
    }

    public sealed class MainSubMenu : ISubMenu
    {
        private ListMenu menu;
        private ShopMenu shopMenu;

        private void OnStartGame()
        {
            Game.Current.StartMatch();
        }

        private void OnEnterShop()
        {

        }

        private void OnEnterScoreboard()
        {

        }

        private void OnEnterSettings()
        {

        }

        private void OnCredits()
        {

        }

        private void OnExit()
        {

        }

        public void Update()
        {
            menu.Position = Game.Current.MainMenu.Animator.Animation.Position;
            menu.Update();
        }

        public void Draw()
        {
            menu.Draw();
        }

        public MainSubMenu()
        {
            shopMenu = new ShopMenu();

            menu = new ListMenu(new Vector3(0, 0, 0));
            menu.AddItem("Играть", OnStartGame);
            //menu.AddItem("Магазин", OnEnterShop);
            menu.AddItem("Настройки", OnEnterSettings);
            menu.AddItem("Рекорды", OnEnterScoreboard);
            menu.AddItem("Выход", OnExit);

            Game.Current.MainMenu.Animator.Play(false);
        }
    }

Кроме того, я реализовал простенький механизм анимации интерфейса, но (пока-что) он используется только в меню.

Доделываем геймплей

Поскольку некоторая часть геймплея у нас уже была, нам нужно найти баланс в различных параметрах геймплея. Из-за примитивности игры, фактически за сложность будет отвечать один параметр(и несколько рандомных факторов) - стадия игры. Стадия игры инкрементируется каждые 35 секунд, повышая сложность игры. Именно на стадии завязаны множители скорости игры, множитель интервала спавна астероидов. Множитель счёта планировалось сделать в зависимости от уровня игрока. Формула ну очень простая(и захардкоженная - так делать не нужно =)):

float prevStage = Info.Stage;
                Info.Stage = Mathf.Clamp(Time / StageTime, 0, 5);

                if ((int)Math.Round(prevStage) < (int)Math.Round(Info.Stage))
                    HUD.ShowAlert("Вы перешли на этап " + (int)Info.Stage, new Vector4(0, 255, 0, 255), 3, true);

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

if (nextSpawn < 0)
            {
                int rand = new Random().Next(0, entityTypes.Count - 1);
                Enemy ent = (Enemy)entityTypes[rand].GetConstructor(new Type[] { }).Invoke(null);
                ent.Position = new Vector3((float)new Random().Next(-World.Bounds, World.Bounds), -5, 150);
                Game.Current.World.Spawn(ent);

                if (Game.Current.World.Info.Stage < 2)
                    nextSpawn = 1.0f;
                else
                    nextSpawn = 0.7f;
            }

Аптечки выпадают слишком часто? Тогда, когда приходит время спавна бонуса, а здоровье игрока ыше 50 и ГПСЧ выбирает аптечку - то мы насильно заспавним бонус.

if (nextPickupSpawn < 0)
            {
                int rand = new Random().Next(0, pickupTypes.Count);

                if (pickupTypes[rand] == typeof(HealthPickup) && Game.Current.World.Player.Health > 50)
                    return;

                Pickup ent = (Pickup)pickupTypes[rand].GetConstructor(new Type[] { }).Invoke(null);
                ent.Position = new Vector3((float)new Random().Next(-World.Bounds, World.Bounds), -5, 150);
                Game.Current.World.Spawn(ent);
                nextPickupSpawn = 20.0f;
            }

Игра строится из мелочей. Одна из таких мелочей - обратная связь, которая создаёт некоторую плавность игры. Например, для эффекта полёта - камера слегка трясётся во время игры. Однако для игрока создаётся эффект, будто бы это корабль туда-сюда витает, и им надо лучше управлять. При столкновении камера трясётся сильнее - сбивая игрока с толку.

Заключение

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

Я одновременно пытаюсь усидеть на двух стульях - и с достаточным кол-вом технических подробностей, и при этом относительно понятным для людей, которые довольно далеки от разработки игр. Получается ли это у меня? Решать вам :)

В любом случае, я надеюсь кому-то этот "дуэт" статей окажется полезным - конечно же исходный код игры доступен на гитхабе, который я постараюсь попозже задокументировать ещё более подробно. Кроме того, в игре всё ещё есть достаточно слабые моменты в плане архитектуры и конкретных участков кода, которые надо ещё полировать. Ниже я публикую видео с геймплеем игры, если вам лень качать. Кроме того, публикую линк на архив с бинарниками(exe под win32 и apk под Android. Под Android, распакуйте ресурсы в память телефона/WMGame3D/GameData/).

https://disk.yandex.ru/d/37UqaBF1VXDU8g

https://github.com/monobogdan/spaceshooterwm

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


  1. bodyawm Автор
    01.11.2022 15:44
    +1

    Статья профильная для хабра и участвует в ППА. Поэтому, если вам статья оказалась полезной или просто понравилась - не поленитесь нажать плюс. Деньги идут на контент(спасибо шейхам с хабра за донат) :)

    Кстати, я вроде бы обещал отчитаться по заказу запчастей на будущий контент - вот и пруф:

    Всем спасибо)


    1. AlexNixon
      02.11.2022 12:39
      +1

      Это бел.рубли что ли? Где такие цены?) Гарнитуры столько даже в фикспрайсе не стоят...


      1. bodyawm Автор
        02.11.2022 13:11
        +1

        Российские) магазин закрывается просто, распродаёт не очень ликвидные товары по дешевке)

        Я для того и заказал - чтобы сравнить звук 5310 XM и k550 :)


        1. AlexNixon
          03.11.2022 09:54

          А дайте тогда в ЛС ссылочку, пожалуйста, поглядим хоть


      1. bodyawm Автор
        02.11.2022 13:12

        Дисплей 50 рублей - и это не шутка)