О чем статья?

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

Оглавление

  • Мотивация

    • Зачем так делать?

    • Почему C++?

    • Что нужно знать, чтобы понять статью?

  • Обработка действий игрока в реальном времени

    • Windows API

    • conio.h + getch

  • Запуск периодических событий по таймеру

    • Windows API

    • std::chrono

  • Генератор случайных чисел

  • Изменение размера экрана консоли

  • Ускорение вывода текста

    • Перестановка курсора

    • Вывод готовых фрагментов текста

    • Настройка буферизации stdio.h

    • Измерение производительности

Мотивация

Зачем так делать?

Чтобы научиться программировать. А точнее - чтобы научиться переносить человеческие мысли в код. Обычные задания по программированию плохо тренируют этот навык потому что они скучные. Признаюсь честно, я намеренно готовил скучные задания для своих студентов. Нудные задания на превращение двух чисел в третье неизбежны, чтобы освоить базовые концепции. Такие задания помогут вам спуститься по горной реке, заполненной острыми камнями синтаксиса и водоворотами новых концепций. Проблема в том, что простые понятные задания однажды бросят вас в бескрайнем океане возможностей. Тогда вы обнаружите, что можете плыть куда угодно. Однако никто не подскажет вам верное направление. Ни одна платформа с автоматической проверкой решений не сможет проверить творческую работу и поставить оценку. Это может сделать либо живой преподаватель, либо живой напарник.

В статье ничего не будет про создание конкретной игры. Потому что конкретная игра - конкретные правила. А правила вы должны придумать сами. Дайте волю своему воображению, вспомните свою любимый жанр и сделайте хорошую попытку. Единственное ограничение - мы не будем рисовать красивую картинку. Потому что красивая картинка займет слишком много сил и будет только отвлекать нас от упоения кодом. Скажу по секрету, как только вы сделаете свою игру с помощью символов в черном окошечке, то сможете воплотить ваш замысел на любой другой платформе. Интересная игра будет интересна даже в консоли.

Почему C++?

Главная причина в том, что я сейчас веду индивидуальные занятия по C++ у одного талантливого студента. Его успехи вдохновили меня, а его вопросы показали о чем вообще нужно написать. C++ до сих пор рекомендуют как "язык для обучения". Это вселяет надежду, что статья будет полезна многим. Может быть когда-нибудь я напишу такую же статью и для других языков или для Linux, но не рассчитывайте на это. Если вы напишете сами подобный сборник советов для другого языка, то сообщите мне личным сообщением. Я добавлю ссылку на ваш труд.

Что нужно знать, чтобы понять статью?

Если вы понимаете концепцию циклов, массивов и функций, то вам должно быть достаточно. Предупреждаю сразу, в статье будут магические конструкции, которые я не буду объяснять. У меня нет цели сделать всеобъемлющий курс по C++. Цель - писать код и радоваться тому, как оно почти магически заработает. Когда закончите с основной целью или когда встретитесь с непреодолимыми проблемами, тогда и углубляйтесь.

Обработка действий игрока в реальном времени

Проходя стандартную пачку учебных задач вы скорее всего использовали std::cin или scanf. Программа ждала от пользователя каких-то данных и нажатия кнопки Enter. Вся осмысленная работа происходила уже после того, как исходные данные получены и проверены. Но в настоящих играх специальную кнопку нужно нажимать только в пошаговых стратегиях. Остальные игры реагируют сразу при нажатии нужных кнопок. Поэтому главный прием для написания игры в реальном времени - моментальная обработка нажатия клавиш. Программа не должна ждать нажатия кнопки Enter. Игра должна работать и параллельно обрабатывать нажатие на клавиатуру. В профессиональном сообществе говорят, что когда нужно нажимать кнопку Enter, то это блокирующий ввод. Соответственно, когда программа работает и при этом готова обрабатывать команды - неблокирующий ввод.

Windows API

Когда я погуглил "non-blocking console input", то SO услужливо выдал мне вот такой вопрос https://stackoverflow.com/questions/6171132/non-blocking-console-input-c К сожалению, у него нет того ответа, который нужен нам. Вариант с фоновым потоком я считаю слишком сложным для того, чтобы кодить для своего удовольствия. Вариант с дополнительной нестандартной библиотекой - тоже. Вся остальная выдача не сильно отличалась по смыслу.

Существует ли такая возможность вообще? Наверняка да. Например в SDL.dll мы делаем графическое приложение и основной способ реагировать на клавиатуру с мышью - обрабатывать системные события. Вот есть целая глава руководства https://www.willusher.io/sdl2%20tutorials/2013/08/20/lesson-4-handling-events При этом тащить всю библиотеку SDL мне совсем не хотелось. Но раз нестандартная библиотека может обрабатывать события, значит любой сможет. Таким образом, чтобы программа работала и при этом моментально обрабатывала нажатия на клавиши, нужно отказаться от scanf и std::cin. Нам нужно пойти глубже - на уровень событий.

Поиск по ключевым словам "handle C++ event" выдало целую кучу бесполезной информации по рисованию окон в каком-то из фреймворков windows. Я все еще намерен писать простую игру в консоли, а не оконное приложение, поэтому игнорирую результаты.

Следующая попытка была "cpp event loop with console input" и мне попалась статья https://docs.microsoft.com/en-us/windows/console/reading-input-buffer-events из которой я и взял решение. Считаю это удачной находкой, потому что в процессе написания этой статьи пытался вспомнить свои запросы и даже слегка видоизмененный "event loop c++ with console application" уже не давал нужной информации. Пришлось смотреть историю поиска. Для будущих поколений я добавил сюда явный текст своих запросов, в надежде что будущие поиски будут более результативными.

Подстава в том, что даже эта статья на самом деле показывает пример блокирующего чтения "The function [ReadConsoleInput] does not return until at least one record is available to be read." Поэтому я посмотрел на соседние статьи и нашел обзор низкоуровневых функций чтения https://docs.microsoft.com/en-us/windows/console/low-level-console-input-functions

В ней описана функция PeekConsoleInput, которая "If no records are available, the function returns immediately.", но которая при этом "Reads without removing the pending input records in an input buffer.". То есть если ее вызвать несколько раз подряд, то она получит информацию об одних и тех же событиях. К счастью там же еще описана функция FlushConsoleInputBuffer, которая удаляет все непрочитанные накопленные события. Сочетание этих двух функций позволит добиться нужного эффекта.

Я видоизменил код из найденной статьи https://docs.microsoft.com/en-us/windows/console/reading-input-buffer-events а именно:

  1. Удалил обработку событий мыши. Если понадобится, спишите ее сами из оригинала.

  2. Удалил колдовство над режимом консоли (функции GetConsoleMode/SetConsoleMode). Для обработки клавиатуры подходит и стандартный режим. Это позволило упростить обработку ошибок в функции ErrorExit.

  3. Добавил сквозной счетчик итераций внешнего цикла. Чтобы было видно работу программы при отсутствии событий.

Получилось вот так:

Обработка событий клавиатуры в консоли Windows API
Обработка событий клавиатуры в консоли Windows API

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

Результат запуска обработки событий клавиатуры в консоли Windows API
Результат запуска обработки событий клавиатуры в консоли Windows API

При запуске будет повторяться одна и та же фраза. Например "iteration 468811 total 106 current 2" где значение после iteration - счетчик, который увеличивается при каждой итерации цикла. Даже если мы ничего не будем нажимать. значение после total - количество событий, которые мы обработали значение после current - количество событий, которые нужно обработать в этой итерации цикла

Допустим мы увидели как обрабатывать события клавиатуры не останавливая работу программы. Что можно с этим сделать?

Чтобы собрать больше информации, я поставил точку останова в функции обработки события и понажимал разные кнопки.

Анализ результатов в отладчике

Например вот так выглядит событие при нажатии "ж".

Вот так - нажатие шифта

Вот так - нажатие "ж" с удерживаемым шифтом. Пришлось переставить точку остановки на "отпускание" клавиши, чтобы программа не реагировала на зажатый шифт. Обратите внимание, что dwControlKeyState отличается от "ж" без шифта.

Вот так - символ точка с запятой на английской раскладке. Это та же кнопка, что и "ж".

Вот так - символ точка с запятой на русской раскладке. Это уже другая кнопка - с цифрой "4".

Вот так - доллар. Он тоже на цифре "4", но на английской раскладке.

Обратите внимание, что во всех вариантах нажатия на кнопку "ж", "Ж", ";" в поле wVirtualKeyCode находится одно и то же число. Если использовать это поле, то управление не будет зависеть от раскладки и даже от зажатого шифта и капслока.

Еще важный момент, что wVirtualKeyCode для точки с запятой ";" на разных клавишах разный, но uChar.UnicodeChar у них одинаковый.

Итого с помощью Windows API мы можем:

  1. Различать нажатые клавиши по коду клавиши независимо от раскладки

  2. Различать нажатые клавиши по коду из Юникода независимо от их расположения на клавиатуре.

  3. Обрабатывать нажатия на shift, ctrl, alt.

  4. Понимать, нажат ли сейчас shift, ctrl, alt.

  5. Отличать нажатие клавиши и ее отпускание (буду благодарен, если подберете слово получше).

  6. Прикрутить обработку событий мыши

Если мы можем отличать нажатые кнопки, то можем и по-разному на них реагировать. Например менять координаты персонажа при нажатии на стрелки. Буду использовать обычную для программ координатную сетку. Ноль в ней находится слева сверху. Ось X увеличивается вправо, а ось Y - вниз. Для обозначения координат персонажа объявил две переменные: x и y с начальным значением 10.

В обработчик нажатия клавиш добавил ветвление. В нем проверяется код клавиши и увеличивается соответствующая переменная.

Для проверки запустил и нажал вниз 1 раз, вправо 2 раза, вверх 3 раза, влево 4 раза. Получился вывод как на картинке. Чтобы сделать скриншот, пришлось подключить отладчик.

conio.h + getch

Мой студент параллельно мне нашел способ обрабатывать нажатия на кнопки с помощью комбинации функций kbhit и getch из conio.h. С помощью getch можно получить код нажатого символа, но эта функция ждет следующего нажатия. Чтобы программа при этом продолжала работать, нужно сначала вызвать kbhit. Эта функция вернет true, если нажата хотя бы одна клавиша, но при следующего нажатия ждать не будет. Если ничего не нажато, то kbhit возвращает false и программа работает дальше.

К сожалению, я сходу не разобрался, в какой кодировке этот код для кириллических символов. Я реализовал прототип с помощью conio и поэкспериментировал с нажатием клавиш. Эта библиотека игнорирует нажатие shift, ctrl, alt, caps lock. Точка с запятой ";" на клавише с "ж" вернет код "59", на клавише с "4" тоже получился код 59. У символов в разных регистрах, например "ж" и "Ж" будут разные коды. У меня получились 166 и 134 соответственно. Интересно, что стандартное преобразование "(char)key" превратило эти коды в совершенно другие символы.

Обработка событий клавиатуры conio.h
Обработка событий клавиатуры conio.h
Результат запуска обработки событий с помощью conio.h
Результат запуска обработки событий с помощью conio.h

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

Запуск периодических событий по таймеру

Как вы можете заметить, если просто отпустить программу в свободный полет, то она будет очень часто обращаться к списку событий. На моем компьютере получается несколько десятков раз за каждую миллисекунду. Само по себе это хорошо, т.к. позволяет оперативно реагировать на все действия игрока. Однако если мир будет меняться с такой же скоростью, то игрок никак не сможет поспеть за ним. Есть несколько способов контролировать период обновления мира. Я выбрал вариант без подключения дополнительных библиотек. Основная идея в том, чтобы постоянно смотреть на время, а обновление мира запускать каждые несколько секунд. Раз уж я докопал до ручной реализации event-loop, то изобрести велосипед с реализацией задержки в рамках выбранной архитектуры не составит труда.

Конкретная реализация нагуглилась с первого запроса "windows.h time milliseconds". https://stackoverflow.com/questions/17008026/windows-how-to-get-the-current-time-in-milliseconds-in-c Для очистки совести я еще попробовал поискать "cpp thread sleep" и "cpp sleep". Первый вариант получился слишком сложный, а второй недостаточно точный.

Поэтому я предлагаю свой вариант. Цель - вписать ожидание в существующий цикл обработки событий. Для этого в начале программы я получаю текущее время. Если бы я работал с обычными часами, то это было бы 17:32:44. Затем я вычисляю время следующего события. Допустим оно должно произойти через 40 минут. Для этого к текущему времени прибавляю длительность ожидания. В моем примере событие произойдет в 18:12:44. Время второго события получается к времени первого события прибавляю длительность ожидания. 18:52:44 Врем третьего события 19:32:44 и так далее.

Существует несколько способов получить текущее время:

  1. С помощью GetSystemTime из windows.h

  2. С помощью std::chrono

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

Получение текущего времени Windows API

Функция GetSystemTime находится в библиотеке . Она записывает структуру с несколькими полями. Каждое поле отвечает за свою компоненту времени: год, месяц, день и так далее включая миллисекунды. Называются они довольно очевидно, так что я думаю вы догадаетесь или воспользуетесь переводчиком. В примере ниже структура со временем лежит в переменной tempTime. В отладчике она выглядит примерно так:

Обратите внимание, что все компоненты времени меняются от 0 до значения не превышающего следующую компоненту. Ну то есть часы от 0 до 23, минуты от 0 до 59. Миллисекунды от 0 до 999. Если сравнивать "в лоб", то возникнет проблема когда последнее обновление было в конце прошлой секунды, а текущее время - в текущей. У этого варианта есть два решения:

  1. Конвертировать полученную структуру в что-то вроде unix timestamp - одно число, начиная с "начала эпохи".

  2. Сделать хитрое сравнение.

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

Код получается вот таким

Получение текущей даты Windows API
Получение текущей даты Windows API

При запуске получается примерно такой вывод:

Результат получения времени из Windows API
Результат получения времени из Windows API

Теперь добавляю дополнительную переменную для хранения времени предыдущего обновления (prevTime) и периода между обновлениями (delay).

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

При запуске получается примерно такой вывод.

Таким образом команда вывода слова "tick" будет выполняться раз в 300 миллисекунд. После нее нужно помещать логику будущей игры.

Получение текущего времени std::chrono

Реализация с помощью std::chrono получилась значительно проще. Но в ней используется непривычное для обычных людей запись времени. Там нет количества часов с начала дня, минут с начала часа и секунд с начала минуты. С помощью std::chrono можно получить количество миллисекунд с "начала эпохи". То есть с 1 января 1970 года. Это получается огромное целое число. Например 1610827417491. В отличие от "количества миллисекунд с начала секунды", это число всегда увеличивается. Поэтому не возникнет необходимости беспокоиться о переполнении часов.

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

Получение текущего времени std::chrono
Получение текущего времени std::chrono

Константа PRIu64 нужна для форматированного вывода значения типа uint64_t с помощью printf. Для её использования нужно подключить inttypes.h Хотелось бы, конечно, не подключать лишних библиотек, но времени на обход именно этой библиотеки у меня не было.

Вывод получился вот таким

Вывод от получения текущего времени std::chrono
Вывод от получения текущего времени std::chrono

Что с этим можно сделать? Навскидку мне в голову пришло:

  1. Симуляция гравитации в платформере. Каждые Х миллисекунд персонаж должен падать на 1 символ.

  2. Полет пуль. Каждые Х миллисекунд передвинь пулю по направлению выстрела.

  3. Движение змейки. Каждые Х миллисекунд передвинь сегменты змейки на один символ от головы.

Что самое важное, у разных событий может быть разный период обновления. Вот прототип, где каждые 300 миллисекунд у персонажа уменьшается координата Х на 1, а каждые 225 миллисекунд - координата Y. При этом игрок может на WASD влиять на эти координаты гораздо чаще.

На картинке ниже вывод, который получается в такой программе время. Обратите внимание на чередование фраз "Each 225" и "Each 300".

Генератор случайных чисел

Если немного копнуть в алгоритм генераторов случайных чисел, то можно обнаружить, что они не такие уж случайные. Поэтому используют более точный термин Генератор псевдо-случайных чисел или ГПСЧ.

По запросу "с++ rand" и "cpp rand" можно найти довольно много материалов. Например обширную статью на русском https://en.cppreference.com/w/cpp/numeric/random/rand и чуть более сухую на английском https://en.cppreference.com/w/cpp/numeric/random/rand

Вкратце перескажу основные тезисы:

  1. Функция rand возвращает случайное число от 0 до RAND_MAX. Значение RAND_MAX зависит от библиотеки, но должно быть не менее 32767.

  2. Если просто вызывать функцию rand, то при разных запусках, последовательность случайных чисел будет одинаковой.

  3. Чтобы последовательность была каждый раз разной, нужно применить функцию srand.

Я от себя добавлю, что аргумент функции srand еще называют зерном (или "сидом" от слова seed). Вы подобное видели во многих играх при создании мира.

Примеры начальных значений для ГПСЧ
Зерно мира в Minecraft
Зерно мира в Minecraft
Зерно мира в Terraria
Зерно мира в Terraria
Зерно мира factorio
Зерно мира factorio

Если зерно мира заполнять одним и тем же значением, то получатся одинаковые последовательности случайных значений. В играх получатся одинаковые миры. Вот пример использования ГСПЧ с постоянным значением зерна.

При запуске у меня появляется фраза "first is 440 second is 19053". Независимо от количества запусков получаются одни и те же числа. У вас могут получиться другие числа, но от запуска к запуску они должны быть одинаковы.

Чтобы последовательность менялась от запуска к запуску нужно передавать такое зерно, которое будет всегда разное. Что у нас не повторяется при запуске одной и той же программы раз за разом?

Ответ - время запуска. Простейший способ задания зерна случайности - взять текущее время. Обычно во всяких примерах предлагают подключить библиотеку ctime и вызывать функцию std::time(nullptr). Этот вариант не устраивает меня потому что библиотека ctime больше ни для чего не используется. Зато при реализации периодических событий была подключена библиотека chrono. В примере ниже зерно мира задается текущим временем.

Инициализация зерна случайности с помощью std::chrono
Инициализация зерна случайности с помощью std::chrono

Я варварски сконвертировал uion64_t в int, но этого оказалось достаточно.

Следующая проблема в том, что числа получаются огромные. Повторюсь, что rand возвращает число от 0 до RAND_MAX, который у меня равен 0x7fff. В десятичной системе счисления диапазон случайных чисел получается от 0 до 32767.

Классический способ уменьшить этот диапазон - применить операцию "остаток от деления". Выражение выглядит как "std::rand() % boundary", где boundary - новое ограничение диапазона.

Вы когда-нибудь задумывались, а почему остаток от деления нам действительно помогает? Не получится ли такого, что какое-то число будет встречаться чаще других?

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

Посмотрите на два столбца чисел. Левый столбец - просто числа по порядку от 0 до 14. Правый столбец - остаток от деления числа из левого столбца на 4.

0 0

1 1

2 2

3 3

4 0

5 1

6 2

7 3

8 0

9 1

10 2

11 3

12 0

13 1

14 2

Как вы можете заметить, диапазон от 0 до 14 разделился на несколько отрезков от 0 до 3. Числа в правом столбце возрастают так же равномерно, как и в левом. Точно такая же закономерность прослеживается и на диапазоне от 0 до 32767. Функция rand() может вернуть каждое число "из левого столбца" с равной вероятностью. После нахождения остатка от деления мы получим соответствующее число из правого столбца.

Обратите внимание, что последний диапазон "от 0 до 3" в правом столбце не успел закончиться. Если числа в левом диапазоне будут выбираться с равной вероятностью, то числа 0, 1, 2 из правого столбца будут появляться чаще, чем число 3. То же самое будет и на полном масштабе, если 32767 не будет делиться нацело на выбранное вами ограничение. Впрочем, исходный диапазон рандома достаточно велик, чтобы мы не заметили этот небольшой недостаток. В документации на cppreference в формуле выбора числа есть попытка компенсировать его делением на "((RAND_MAX + 1u)/6)".

Как уже говорилось выше, рандом возвращает числа от 0 до ограничения. Проблема в том, что при написании игры нужно использовать числа не от 0, а например от 10 до 20. В реализации std::rand приходится упражняться со сложением и вычитанием. К счастью, я нашел ссылку на статью с описанием синтаксиса, который был добавлен в стандарт C++ от 2011 года. https://en.cppreference.com/w/cpp/numeric/random/uniformintdistribution В нем есть красивый способ описать рандом в нужном диапазоне. Немножко оформления и можно просто получать случайные числа в нужном диапазоне.

#include <random>
#include <stdio.h>

int main()
{
	std::random_device rd;//Источник зерна для рандома
	std::mt19937 gen(rd());//Вихрь Мерсенна (Mersenne Twister) 
	std::uniform_int_distribution<> oneToSix(1, 6);//функция распределения от 1 до 6
	std::uniform_int_distribution<> twentyToForty(20, 40);//функция распределения от 20 до 40
  //Два числа в диапазоне от 1 до 6
  int first = oneToSix(gen);
  int second = oneToSix(gen);

  printf("%d %d ", first, second);
  
  //два числа в диапазоне от 20 до 40
  int third = twentyToForty(gen);
  int fourth = twentyToForty(gen);

  printf("%d %d", third, fourth);
 
  return 0;
}

Подробнее о Вихре Мерсенна можно почитать на Википедии https://ru.wikipedia.org/wiki/%D0%92%D0%B8%D1%85%D1%80%D1%8C_%D0%9C%D0%B5%D1%80%D1%81%D0%B5%D0%BD%D0%BD%D0%B0

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

Изменение размера консоли

По умолчанию у меня в консоли помещается 80 символов в высоту и 50 в ширину. Это примерно треть моего экрана, поэтому захотелось увеличить количество символов в строке консоли хотя бы до 200.

Изменение размера окна консоли тоже оказалось задачкой с подвохом. Первый запрос был тривиальным "c++ change size of console window". Первый ответ на него подробно объяснял как сделать это с помощью настроек окна консоли на уровне операционной системы. То есть не из самой игры, а со стороны пользователя. Прикладывать эту инструкцию к игре я посчитал неправильным. Нужен способ сделать это из самой программы. Второй и последующие ответы описывали изменение размера окна консоли с помощью функции MoveWindow. Фактическое количество текста при этом не менялось. Если окно становилось слишком маленьким, то появлялись полосы прокрутки.

Следующая попытка была "c++ set console size". Два первых ответа вели на известные советы с функцией MoveWindow. Зато дальше пошли ссылки на документацию. А именно - на функцию SetConsoleScreenBufferSize. Судя по описанию, она меняет не размер видимого окна, а внутренний размер буфера. В качестве аргументов она принимает поток вывода и структуру с желаемыми размерами буфера.

На тот момент я не знал точно, какие размеры стандартные и что я могу туда поставить. Поэтому указал 20 на 20. Для проверки размеров окна я также вывел прямоугольник из цифр от 0 до 9 шириной 20 на 20. Получился вот такой код:

Заполнение экрана символами и изменение размера консоли
Заполнение экрана символами и изменение размера консоли

Вывод получился вот таким

Поскольку это работа на уровне WinAPI, в результате получился код ошибки. Я в основном работаю с java стеком и обычно вижу стектрейсы и тексты исключений. Несмотря на это, принцип решения проблемы не изменился. Для расшифровки кода ошибки нужно воспользоваться официальной документацией. Она легко ищется запросом "getlasterror error codes". Кодов ошибок описано около девяти тысяч на нескольких страницах. Для моего случая подойдет первая страница https://docs.microsoft.com/en-us/windows/win32/debug/system-error-codes--0-499-

Ошибка гласит ERROR-INVALID-PARAMETER 87 (0x57) The parameter is incorrect.

Маловато объяснений. Тогда я проверил как другие пишут этот код. Запрос "SetConsoleScreenBufferSize incorrect argument" привел меня вот на этот вопрос на SO https://stackoverflow.com/questions/12900713/reducing-console-size

В ключевых аспектах код ответа был похож на мой. Но в нем содержалось важное дополнение "If you call SetConsoleScreenBufferSize with illegal value in COORDS (e.g. too little height/width) then you get an error, usually 87 'invalid argument'."

Потом я посмотрел в документацию к функции SetConsoleScreenBufferSize https://docs.microsoft.com/en-us/windows/console/setconsolescreenbuffersize и увидел что на размеры буфера наложены ограничения. Получается, что я передал слишком маленькие значения. У меня не было необходимости перебирать значения для получения точных минимальных размеров. В конце концов цель - увеличить размеры буфера, а не уменьшить. Поэтому показалось логичным отталкиваться от текущих размеров окна. Раз у нас есть функция SetЧтототам, значит должна быть и функция GetЧтототам. GetConsoleScreenBufferInfo действительно нашлась https://docs.microsoft.com/en-us/windows/console/getconsolescreenbufferinfo С помощью неё и отладчика MSVS я выяснил, что размеры буфера на моей машине по умолчанию 80 на 50. Ширину я увеличил примерно в три раза, а высоту в полтора. При инициализации структуры size значением X = 200 и Y = 80 в высоту появились полосы прокрутки. Здесь и пригодилась функция MoveWindow.

Исходный код был видоизменен вот так:

Вывод при этом получился таким

Ускорение вывода текста

Запустив программу для заполнения большого экрана символами, я обнаружил, что текст пишется в консоль очень медленно. Заполнение символами экрана 200 на 80 заметно человеческому глазу. За одну секунду получится обновить экран лишь 1-2 раза. Это вряд ли связано с производительностью компьютера. Интуиция подсказывает, что это искусственное ограничение. При решении этой проблемы у меня было два направления поиска:

  1. Как быстро написать много текста?

  2. Как обновить только тот фрагмент экрана, который действительно менялся?

Сначала я поискал "cpp console reduce delay between screen updates". Практически все ссылки вели на советы по добавлению паузы, что мне совершенно не интересно. Только один ответ в выдаче говорил что-либо об ускорении вывода https://stackoverflow.com/questions/26376094/c-writing-to-console-without-delays. В нем предлагается подготовить большой буфер в памяти и вывести его одной командой.

Затем я поискал "windows.h write lot of text without animation" и нашел вот такой вопрос с очень любопытным ответом. https://stackoverflow.com/questions/34842526/update-console-without-flickering-c

Автор вопроса и автор ответа разговаривают в контексте создания консольной игры. Вместо полной очистки и полного заполнения экрана в ответе предлагается:

  1. Переставить курсор и писать новый текст поверх старого без предварительной очистки экрана.

  2. Переписывать фрагменты, а не отдельные буквы.

  3. Переписывать отдельные буквы на экране.

  4. Использовать два буфера и писать на экран только разницу.

Обратите внимание, в ответе на вопрос есть еще пример настройки цвета текста в консоли. У меня, к сожалению, не хватило времени воспроизвести этот прием.

Перестановка курсора

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

За основу был взят код из главы "Изменение размера консоли".

Для своего удобства я реализовал функцию заполнения структуры нужными координатами.

Функция подготовки структуры с координатами
Функция подготовки структуры с координатами

Для перестановки курсора достаточно вызвать функцию SetConsoleCursorPosition и передать ей в качестве аргументов поток вывода и структуру с координатами желаемой позиции курсора.

Вызов функции SetConsoleCursorPosition для перестановки курсора
Вызов функции SetConsoleCursorPosition для перестановки курсора

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

Вывод готовых фрагментов текста

Следующая попытка - писать символы не по одному, а строчками. Для этого нужно подготовить массив символов в памяти, который я далее буду писать как целую строку. Длина этого массива совпадает с шириной окна консоли. Во внутреннем цикле массив будет заполняться, а во внешнем - выводиться на экран. Ранее я выводил цифры с помощью форматированного вывода. Теперь нужно писать именно цифры. Для простой конвертации числа в символ нужно знать коды этих символов. У цифры "0" код символа 48, у цифры "9" - 57. То есть к числу от 0 до 9 включительно достаточно просто прибавить 48. Напомню, что последний символ в буфере обязательно должен быть "\0", чтобы не ловить баги, связанные с выводом нежелательных символов. За основу я взял код из раздела про изменение размера консоли.

Вывод текста фрагментами
Вывод текста фрагментами

В примерах еще часто использовался std::cout.flush(), который тащит за собой подключение iostream. Мне не хотелось использовать дополнительную библиотеку. Наверняка аналог есть и в stdio, который уже подключен. Мой запрос для поиска был "stdio flush output". Две ссылки на Stackoverflow указывают на fflush

  • https://stackoverflow.com/questions/12450066/flushing-buffers-in-c/12450125

  • https://stackoverflow.com/questions/1716296/why-does-printf-not-flush-after-the-call-unless-a-newline-is-in-the-format-strin

Функция fflush вызывается с аргументом stdout. Я минут 10 искал как правильно заполнить переменную stdout, а оказалось она просто доступна из глобальной области видимости.

Настройка буферизации stdio.h

Подозреваю, что в stdio.h буферизация вывода уже реализована, поэтому мой код скорее всего оказался велосипедом. На момент оформления этой статьи я уже забыл как искал информацию. Главное - для настройки буфера с помощью stdio.h нужно воспользоваться функцией setvbuf. Она принимает stdout, буфер, какие-то флаги и число - размер буфера.

Настройка буфера вывода с помощью stdio.h
Настройка буфера вывода с помощью stdio.h

Измерение производительности

Тут я осознал, что не в состоянии сравнить производительность разных вариантов "на глаз". Поэтому вкрутил измерение времени до и после обновления экрана. Напишу тут порядок цифр, потому что значения с точностью до микросекунд не существенны для сравнения. Код практически полностью списал из ответа к этому вопросу https://stackoverflow.com/a/21856299

Функция получения времени с точностью до микросекунд
Функция получения времени с точностью до микросекунд

Само измерение - тривиально. Получаю текущее время до и после отрисовки экрана. Затем вычитаю и вывожу на экран. Есть способы лучше, но для моей задачи этого оказалось достаточно. Код ниже - развитие варианта с настройкой буфера с помощью stdio.h Код варианта с велосипедным буфером принципиально не отличался, поэтому не публикую его.

Измерил количество микросекунд без явного указания буфера. Три запуска, три числа: 8930000 8880000 9220000.

с размером буфера 1/16 от 740000 до 750000 микросекунд

с размером буфера 1/8 от 40000 до 39000 микросекунд

с размером буфера 1/4 от 18000 до 19000 микросекунд

с размером буфера 1/2 от 12000 до 13000 микросекунд

с размером буфера равным ширине консоли от 90000 до 10000.

Во всех этих случаях наблюдается мигание строк на экране размером с буфер. То есть при буфере 1/4, мигают фрагменты в четверть строки. С буфером равным ширине консоли получается очень большой разброс, причем без промежуточных значений. либо за 10000, либо за 90000. При этом мигает так, как будто буфер половина. redraw настройка буфера библиотеки.PNG

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

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

Заключение

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

В качестве бонуса - ссылка на подробный разбор вывода русского текста в консоль. https://ru.stackoverflow.com/questions/459154/%D0%A0%D1%83%D1%81%D1%81%D0%BA%D0%B8%D0%B9-%D1%8F%D0%B7%D1%8B%D0%BA-%D0%B2-%D0%BA%D0%BE%D0%BD%D1%81%D0%BE%D0%BB%D0%B8

P.S. Если вы нашли опечатки или ошибки в тексте, пожалуйста, сообщите мне. Это можно сделать выделив часть текста и нажав «Ctrl / ? + Enter», если у вас есть Ctrl / ?, либо через личные сообщения. Если же оба варианта недоступны, напишите об ошибках в комментариях. Спасибо!