Делаю игрулю на Playdate на чистом C. Глава 4
Я пишу игру на игровую консоль Playdate на чистом C. Игра в жанре "выживальщик" наподобие Vampire Survivors. Так как в чистом C отсутствуют многие современные объектно-ориентированные удобства мне приходится по-всякому изворачиваться чтобы адаптировать свои идеи в код. В этих заметках ты узнаешь из первых уст как создаётся игруля с нуля от идеи до публикации.
Если ты не читал предыдущие главы, то лучше начать с них.
Глава 1 - создание аналога объекта динамического массива для будущих нужд на чистом С;
Глава 2 - программирование внедорожника и объектов пустыни, инициализация и очистка ресурсов игры;
Глава 3 - описание процессинга тика, в частности, обработка пользовательского ввода, а также обновление модели данных.
====================
В этой главе тебя ждут математика за пятый класс, пьяные перекати-поле и обуздание неопределённого поведения.
Итак, большинство людей в мире визуалы. Это значит, что им привычнее всего воспринимать информацию глазами. В прошлых главах я создал целый мир, но какой в этом смысл если это невозможно увидеть? Нет, конечно можно в баре рассказывать про то, какой невероятный код я написал, но собеседник не сможет его увидеть так как у него нет Playdate (ты же помнишь, что я живу в Казахстане? У нас на всю страну три человека имеют Playdate), ну и потому что собеседник бухой в щи, пьяный в зюзю, надрался, под мухой, на рогах, зелёный как снег.
В общем, в чему это я... Наша заветная функция GameDraw... Она рисует игру (внезапно). Напомню, у нас есть машинка (внедорожник или "джип"), перекати-поле, кактусы, насыпи песка и на этом всё.
Начинаем мы всё с отрисовки машины. На самом деле машину мы не рисуем на верхнем скриншоте, но комментарий на строке 233 утверждает что рисуем. Так уж вышло. Если помнишь в первой главе я тебе рассказывал, что машину мне нарисовала моя художница в восьми вариантах так как мы крестовиной можем указывать 8 направлений как на любой уважающей себя консольке. Все эти 8 картинок хранятся в массиве game->vehicleImage (об это я тоже рассказывал в предыдущих главах), а индекс картинки в этом массиве мы определяем по направлению машины на строке 234. Мы вызываем хитрую функцию GameVehicleImageIndexFromAngle, которая имеет максимально простую логику:
Далее нам надо проделать фокусы с картинкой. Так как картинку мы хотим центрировать по позиции машины на карте (а в итоге в экране) нам следует раздобыть размеры картинки. На строке 238 это как раз и происходит. Функция getBitmapData у PlaydateAPI возвращает всякую полезную инфу о картинке, при этом выходные данные указываются аргументами функции в виде указателей. Если передаёшь NULL, значит данные не получишь. Потому последние три аргумента это NULL потому что это данные для сырых байтов картинки, маски и данных картинки (не помню чем это точно отличается от сырых байтов, если честно), а это мне пока не надо, мне надо только ширину и высоту, заверните в пакет, пожалуйста, взболтать, но не смешивать. Значения newVehicleImageWidth и newVehicleImageHeight мне пригодятся чуть позже. Едем дальше.
На строке 241 мы очищаем экран белым цветом функцией fillRect. 1 в конце это белый, а если поставить 0 - будет чёрный. Фон мы перерисовываем на всём экране. Размер экрана 400 на 240 пикселей как я упоминал в прошлых главах.
Далее мы рисуем окружение. Первое это кактусы.
Кактусы рисуются относительно позиции машины, то есть, кактусы не центрируются по экрану, а значит нам надо хитрожопо высчитывать их позицию на экране. А ещё важная деталь: заранее условимся, что картинка кактуса будет "крепиться" к позиции кактуса на карте нижней серединкой. То есть, если кактус находится в позиции x = 5; y = 5, то рисовать картинку кактуса надо в позиции x = 5 - width / 2, y = 5 - height, где width и height это ширина и высотка картинки кактуса соответственно. Почему именно нижняя серединка? Потому что мы смотрим как будто в 3D, и мы как будто втыкнули кактус его жопкой в поле, словно канапэшку в пенопласт. Для всего этого мы вытаскиваем размеры картинки кактуса на строке 248 знакомой функцией getBitmapData.
(UPD: на самом деле в коде кактус центрируется, это уже в будущих коммитах будет изменен якорь кактусов, а пока как есть).
Далее мы идём по массиву кактусов циклом, и на строке 252 у нас в итерации есть указатель на очередной кактус, константный указатель, потому что при прорисовке мы не намерены менять состояние кактуса. Всё это делается ради вызова функции drawBitmap на строке 259. Именно эта функция рисует картиночку на Playdate, и именно эта функция будет главной героиней в этой главе. Функция drawBitmap простая как автомат Калашникова. Она принимает 4 аргумента: непосредственно картиночка для отрисовки, координата x, координата y и ещё enum'чик указывающий хотим ли вы нарисовать картиночку развёрнутой по какой-либо оси (нам это не понадобится, но если понадобится я обязательно тебе сообщу, зуб даю, век воли не видать). Координаты x и y указываются на экране девайса. PlaydateAPI не в курсе про мою сцену и объекты на ней - это всё мои личные абстракции, которые я придумал чтобы лучше воспринимать строительство игры после опыта со всякими Unity, Godot и cocos2d-x.
На строке 256 есть cactusRect - это прямоугольник отрисовки кактуса в координатах экрана. Если этот прямоугольник не пересекается с прямоугольником экрана, то drawBitmap мы не вызываем. На самом деле эта проверка избыточна потому что операционная система при вызове drawBitmap тоже делает такую же проверку внутри. Но на тот момент я решил, что так надо. Пересечение проверяется функцией RectIsOutOfBounds
С кактусами всё понятно? Как не всё? А что не понятно с позицией? Ладно, объясняю. У кактуса есть позиция, её мы вытаскиваем в удобненькую константу на строке 253. А на следующих двух строках мы вычисляем x и y - координаты кактуса в координатах экрана. Для этого преобразования используем простую линейную функцию: берём game->cameraOffset со знаком минус, вычитаем половину размера картинка кактуса, прибавляем половину размера экрана и непосредственно позицию кактуса в поле игры, посолить, поперчить на глаз (всегда фигел с выражения "поперчить на глаз" - это ведь для глаза крайне неприятно, хотя я ни разу не пробовал). Почему именно такая формула? Блин, а можно я не буду рассказывать? Ну просто мне лень. Спасибо, друг!
Теперь рисуем кучки песка.
Тут всё ровно то же самое, что и у отрисовки кактуса, только вместо массива кактусов game->cactuses мы итерируемся по массиву песка game->sands. И картиночка у нас game->sandImage, а не game->cactusImage. Вообще некоторые из вас тут возразят "зачем повторять код, в ООП это легко делается одним циклом, а если ECS использовать, то вообще можно в космос улететь". Да. в ООП это занимает меньше кода, но это чуть увеличивает время рантайма из-за виртуальных вызовов (да, в ECS тоже). Не то чтобы очень много времени, это экономия на спичках, и эта экономия не является моей целью, но мы же в сишке, тут у нас нет виртуальных функций. Можно наворотить указатели на функцию, и как-то в итоге сэмулировать виртуальную таблицу, но это всё равно будет не то потому что указатель this не будет работать из коробки.
А что у нас далее? Далее идёт отрисовка перекати-поле. И тут не всё так банально как у кактусов и куч песка. У перекати-поля есть тень, а сам объект подпрыгивает по синусоиде как описывалось в прошлой главе. Давай я ещё раз покажу видосик как оно всё выглядит.
А вот как выглядит код отрисовки перекати-поля:
Тут у нас два цикла вместо одного. Это потому что сначала мы рисуем тень, а потом уже саму тушку перекати-поля. И заметь, что сначала рисуются все тени, а потом уже все тушки. Можно сделать всё одним циклом и рисовать сначала тень, потом тушки каждого объекта по очереди, и я так и сделал в начале, но в таком случае получается так, что при пересечении друг с другом разные перекати-поле могут иметь тень поверх тушки. То есть, тень второго в цикле перекати-поля рисуется после тушки первого, и если их позиции окажутся рядом на карте, то визуально получится, что тень второго как бы "выше" тушки первого, что в реальной жизни невозможно потому что в жизни тень всегда рисуется на поверхности, на которую эта тень падает (в нашем случае это плоскость земли (нет, я не фанат плоской земли)). В общем, если делать один цикл, то выглядеть всё будет обсосно. Насколько обсосно? Давай покажу гифкой:
![Делаю игрулю на Playdate на чистом C. Глава 4 Программирование, IT, Программист, Playdate, Gamedev, Австралия, Сафари, Видео, Гифка, Длиннопост](https://cs13.pikabu.ru/post_img/2024/06/12/9/1718202823130145070.jpg)
Тень поверх тушки
Вот поэтому сначала рисуются все тени перекати-поля, а потом все тушки. Более того, если в будущем у других объектов тоже будут тени, то они тоже должны рисоваться до отрисовки тушек всех объектов. Такая вот логика, и мы её заложники независимо от платформы.
Первый цикл банальный и привычный:
получаем константный указатель на объект Tumbleweed (строка 295);
достаём его позицию в отдельную константу чисто для удобства (строка 296);
вычисляем x и y тени для отрисовки на экране (строки 297 и 298) - позиция объекта Tumbleweed это на самом деле позиция центра его тени;
если получившийся прямоугольник хотя бы одним пикселем наслаивается на экран, то рисуем (строка 302).
Интересности начинаются во втором цикле. Во-первых, мы повторяем первые строки первого цикла во втором цикле (строки 308-311) и я ничуть не сожалею об этом. В смысле, такой код неверен в академическом смысле потому что повторение кода это фу-фу-фу, надо повторяющийся код вынести в отдельную функцию, вызывать эту функцию из разных точек, покрыть её юнит-тестами, SOLID, отжайл, митинги-скрам-мастера, чистый код, ретроспектива, планнинг покер и Джон Кармак. Но мне как-то поровну в данном случае потому что тут мы ничего не теряем в рантайме, а повторяющийся код суперпростой, да и у меня нет цели сделать академически верный код (вообще это плохая цель делать академически верный код потому что практически такой код никому и никогда не нужон). Мне нужно сделать код, который можно написать в разумные сроки, потом без трудностей прочесть, и чтобы этот код чётко исполнял свои цели. Как видишь, академической верности в этом списке нет. В общем, повторяю я код, не учитесь у меня красивому коду, детишки.
Во-вторых, я проверяю что tumbleweedFrameIndex находится в пределах от [0; 4) на строке 315. Эта константа это тикающий индекс кадра объекта перекати-поля. Если индекс привести в целому числу (а то он-то сам по себе float), то это будет индекс картинки перекати-поля в массиве картинок используемых для отрисовки. Всего их 4 штуки, как я ранее упоминал. И tumbleweed->frameIndex тикает в секции обновления данных (описывалось в третьей главе), и там же проверяется на выход их границ, но я тут всё равно его проверяю. Зачем? Просто для верности. Так как на строке 319 я лезу в массив по этому индексу мне надо быть уверенным, что индекс валиден. Потому что если индекс будет невалиден, а я всё равно обращусь к массиву по такому индексу, то я не получу исключение как C#, Swift или даже C++ (std::vector::at кидает исключение), а просто получу какое-то значение, которое не будет представлять ничего вразумительного. Такое поведение называют UB или undefined behavior - неопределённое поведение. Конкретно в данном случае понятно что будет - я просто чуть выйду за пределы массива, получу реальные данные приведённые к указателю на картинку, и потом когда я её буду пытаться отрисовать на строке 326 игруля будет бурагозить: либо отрисует полную ерудну (видал такое), либо операционная система грохнет процесс потому процесс будет пытаться залезть в чужой кусок памяти, либо ещё какая дичь может случиться.
А давай не отходя от кассы так и сделаем! Смотри: на строке 319 мы обращаемся к массиву по максимально правильному кошерному проверенному индексу. Но мы с тобой устроим моему коду небольшой саботаж! Давай я вместо
LCDBitmap *tumbleweedImage = game->tumbleweedImage[tumbleweedFrameIndex];
напишу
LCDBitmap *tumbleweedImage = game->tumbleweedImage[tumbleweedFrameIndex + 1];
то есть, сымулируем выход индекса за границы на единицу и посмотрим что получится. Итак, код саботировали, компилируем, запускаем (звук запуска ракеты, Илон Маск радостный смотрит в небо ладошками обхватив свою голову, телеканал Хабар ведёт прямую трансляцию, а меня выписывают из программистов за намеренное UB в коде).
![Делаю игрулю на Playdate на чистом C. Глава 4 Программирование, IT, Программист, Playdate, Gamedev, Австралия, Сафари, Видео, Гифка, Длиннопост](https://cs13.pikabu.ru/post_img/2024/06/13/5/171826177017739341.jpg)
(Музыка из "Секретных материалов")
И внезапно мы получаем очень странную картину: кадр тушки перекати-поля иногда превращается в свою же тень отображая неправдоподобную ситуацию - двойную тень. Представь в жизни такое: идёт человек, отбрасывает тень, и иногда вместо самого человека в воздухе висит ещё одна его тень. Почему так получилось? Почему неопределённое поведение не уронило игру вместо этого? Причина в том, что сразу после массива в памяти лежит как раз картинка тени. А порядок полей в структуре в сишке гарантируется. Помнишь, во второй главе я показывал структуру Game? Там на строках 20 и 21 как раз расположились массив кадров тушки перекати-поля и его тень. Массив имеет 4 картинки, то есть, это как если бы я просто объявил 4 поля с картинками кадров подряд - в памяти оно бы лежало так же. И следом лежит картинка тени, что схематично идентично если бы вместо массива на 4 и одной картинки был массив на 5. То есть, вместо индексов [0; 4) мы используем индексы [1; 5), и последний индекс равный 4 как раз попадает в тень. Да, это тебе не C# который бы плевался исключениями. У нас сишка с контролируемым неопределённым поведением, мазафака!
Раз такая пьянка, я позволю себе отвлечься и рассказать обалденную историю из моего опыта программирования на Objective-C, которая произошла в бородатые времена когда ещё не существовало Свифта, и вся нативная iOS-разработка велась как раз на Objective-C. И вот я пишу код, у меня класс на Objective-C, у него тоже есть поле статичный массив из 20 объектов, а объекты в Objective-C в принципе хранятся как сишные указатели всегда. Объекты в этом массиве наследуют один протокол (интерфейс в C# и Java и абстрактный класс без полей в C++), и у меня есть индекс в виде int'а, по этому индексу я лезу в массив и вызываю протокольные функции. Чуешь чем пахнет? Я, значит, написал весь этот код, запускаю прилажку на своём айфончике, и в определённый момент получаю исключение говорящее, что я вызываю протокольную функцию у класса (!) в котором все эти поля хранятся, то есть, как будто бы эта функция статичная, хотя я точно не вызываю статичные функции нигде - я проверил свой код несколько раз. Однако при запуске я стабильно получаю ошибку, что вызываю протокольную функцию у самого класса, в котором лежит указанный мной массивчик, а реализации этой функции у класса нет.
Адепты Objective-C скорее всего уже имеют ответ. А произошло вот что. Я же обращаюсь к массиву по индексу, который храню в том же классе. И в роковой момент, когда бросалось исключение, этот индекс был равен -1. Это означает, что при доступе к массиву (а массив сишный, не обжэктивсишный) мы вышли на одно значение назад. В примере выше с перекати-полем я вышел на одно значение вперёд, потому зацепил картинку, которая лежит в памяти следом, точнее, указатель на картинку. Тут в Objective-C я вышел за пределы массива назад. Что это значит? То же самое, что и при выходе вперёд, только надо смотреть на то, что лежит в памяти до массива. Осознав это в моменте я пошёл смотреть на поля класса. Но вот в чём незадача: массив лежит самым первым полем, до него полей у класса нет. Почему тогда вызов протокольной функции распознаётся как будто статичный? И в этот момент мне пришло осознание. В Objective-C объекты это тоже структуры под капотом, но у каждого объекта перед его полями лежит один особый указатель. Это указатель на его класс-объект. Класс-объект это аналог виртуальной таблицы в С++, но на стероидах, потому что он хранит всю информацию о публичных функциях в таком формате, который позволяет проитерироваться по функциям и свойствам класса, и даже добавить новые прямо в рантайме словно мы пишем на JavaScript'е. И ещё важная деталь про Objective-C - вызов функций членов класса в Objective-C это не то же самое, что и в С++, где ты просто как в сишке получаешь адрес функции, подставляешь аргументы и поехали. В Objective-C ты отправляешь сообщение объекту - это более высокоуровневая операция. И ты можешь послать любое сообщение любому объекту с любыми аргументами. Это в С++, C#, Java и Свифте если ты попытаешься у класса вызвать функцию, которой в нём нет, ты получишь ошибку компиляции. А в Objective-C всё скомпилится, но будет ошибка в рантайме говорящая, что данный класс не может ответить на такое сообщение. Изначальное исключение как раз было об этом: что класс не имеет вот такую статичную функцию. Я для прикола взял и реализовал такую статичную функцию у класса и поставил бряку (breakpoint, точка остановки в дебаге) в ней, и тут-то я всё понял окончательно. Из-за индекса равного -1 мы смещаемся от массива, который по совместительству является самым полем класса, на класс-объект этого объекта, любые сообщения посланные объектам-классам считаются вызовами статичных функций, реализации статичной протокольной функции у меня не было (хотя в конце я её добавил исключительно ради эксперимента), и в итоге мы упали с исключением. Вот до чего доводит неопределённое поведение!
Окэй, вернёмся к перекати-полю. Вообще изначально я тебе рассказывал про особенность второго цикла в отрисовке перекати-полей. Первое, что я обозначил, это повторение кода. Второе - проверка tumbleweedFrameIndex чтобы он не вышел за пределы. Думаю, теперь тебе мои намерения проверки индекса стали намного яснее. Я не утверждаю, что так должен делать каждый программист, я лишь поясняю почему я мыслю таким образом, а делать тебе так же в своём коде или нет - это уже тебе решать. В третьих, интересные штуки происходят на строке 325. Мы пересчитываем значение переменной y для отрисовки тушки перекати-поля. x мы оставляем как есть потому что ширина поля и ширина тушки одинаковые. А y должен вилять вверх-вниз по синусиоде как я показывал на графике в третьей главе. Для этого у каждого объекта перекати-поля есть поле tumbleweed->jumpAngle, уверен, ты помнишь об этом. Это "вращающееся" float значение, от которого мы берём синус (на самом деле косинус (строка 323), но синус это косинус со смещением, так что всё норм). На строке 323 как раз происходит взятие модуля от косинуса - то, о чём я рассказывал в конце прошлой главы. А на строке 325 мы умножаем это значение на 13 и рисуем чуть выше от тени (на 20 пикселей, внимание на константу spaceBetweenTumbleweedAndShadow на строке 322). Чтобы лучше показать важность коэффициента 13 давай мы его немного изменим - в два раза и в десять раз - и глянем что у нас получилось.
![Делаю игрулю на Playdate на чистом C. Глава 4 Программирование, IT, Программист, Playdate, Gamedev, Австралия, Сафари, Видео, Гифка, Длиннопост](https://cs15.pikabu.ru/post_img/2024/06/13/7/1718276744178754941.jpg)
Как оно было бы, если были бы другие цифры
Вот потому там 13. В конце цикла мы просто рисуем наконец-таки саму тушку перекати-поля уже известной функцией drawBitmap.
Ну и на этом всё. Нет, конечно ещё в конце рисуется машина. Но там всё супербанально, что я даже не вижу смысла показывать это: вычисляем по известной формуле координаты x и y и рисуем newVehicleImage, которую мы высчитали в самом начале функции GameDraw, функцией drawBitmap.
Ладно, так и быть, давай я расскажу как высчитываются координаты. В корне всего лежит линейная функция. Давай глянем ещё раз на вычисление в коде у перекати-поля
int x = -game->cameraOffset.x - tumbleweedShadowImageSize.x / 2 + screenWidth / 2 + tumbleweedPosition.x;
int y = -game->cameraOffset.y - tumbleweedShadowImageSize.y / 2 + screenHeight / 2 + tumbleweedPosition.y;
и у кактусов
const int x = -game->cameraOffset.x - cactusImageWidth / 2 + screenWidth / 2 + cactusPosition.x;
const int y = -game->cameraOffset.y - cactusImageHeight / 2 + screenHeight / 2 + cactusPosition.y;
В обеих формулах виден паттерн, который можно записать вот так:
screenPosition = -cameraOffset - imageSize / 2 + screenSize / 2 + objectPosition
Это и есть наша линейная функция. Как мы к ней пришли? Давай попробуем придумать её с нуля, это занимательное занятие.
Если мы допустим, что формула на самом деле вот такая
screenPosition = objectPosition
то если кактус будет иметь позицию {5; 5}, то и рисоваться он будет в позиции {5; 5} на экране всегда независимо от позиции машины. Это заведомо неверная формула, но с этого мы начинаем. Более того, в позиции {5; 5} будет верхний правый угол картинки кактуса, что не совсем то что мы хотим - мы хотим чтобы картинка кактуса была центрирована относительно той самой точки. Для центрирования надо от исходной позиции отнять половину размера. Таким образом, формула превратилась в
screenPosition = objectPosition - imageSize / 2
Теперь давай прикинем камеру. Камера в теории может летать над миром как пожелает. И если, скажем, камера отъедет от нулевой позиции влево на 5 пикселей, то есть cameraOffset будет равен {-5; 0}, то кактус находящийся в позиции {5; 5} должен будет нарисоваться наоборот правее в позиции {10; 5} минус половина размера его картинки. Если камера отъедет на {-10; 0}, то кактус переедет на {15; 5} - ещё правее на экране. То есть, нам для вычисления позиции объекта на экране надо отнимать позицию камеры. Именно таким образом формула превратилась в
screenPosition = -cameraOffset - imageSize / 2 + objectPosition
Всё хорошо, вот только камера не центрирована на экране, а лево-верхнирована, то есть, "приклеена" не к центру экрана, а к верхнему левому углу потому что это нулевой угол, то есть, угол по-умолчанию, с которого начинается жизнь в координатной плоскости. Чтобы камеру центрировать надо её сместить на половинку размера экрана. В итоге получаем нашу линейную функцию
screenPosition = -cameraOffset - imageSize / 2 + screenSize / 2 + objectPosition
Достаточно легко на самом деле, и это, по идее, курс математики за пятый класс - линейные функции на координатной плоскости. Тот редкий случай когда школьная программа пригодилась мне во взрослой жизни!
Sagittarius serpentarius - Стрелец-Змеелов - это - я, привет, савубона
Это-латинское название птицы-секретаря, символа ЮАР, где я уже живу более 12 лет.
На первом же моем африканском сафари мне повезло.
-Эй, остановите машину, там какой-то странный орел носится поверх травы! Трава была около 1м 20 см, а у "орла" виднелась только голова. Потом он выскочил на открытое пространство и местные радостно закричали:
-Так это же птица-секретарь! Тебе повезло! Мы сами ее видим впервые!
Потом последовал танец со змеей, с которой именно в данный момент разбиралась эта высокая, красивая, сильная птица с головой орла и венценосными перьями вместо короны.
Она производила бешенные зигзагообразные движения прямо перед змеей, та металась вслед за движениями, в итоге, измотанная и дезориентированная, кинулась со смертельным ядовитым укусом прямо на выставленное в сторону широкое и большое бело-черное крыло птицы-секретаря. Впрыснув в перья всю дозу и упав, обессилев в траву, змея моментально оказалась в клюве обманувшей ее целой и невредимой птицы
Птица-секретарь-главный символ на гербе ЮАР. Пусть посидит у меня на аве. Я и стрелец, и писатель, в некотором роде)) Пишу же здесь, не пою)
Естественный отбор
Во время сафари в заповеднике Масаи Мара в Кении туристы засняли жестокую, но естественную в дикой природе сцену: нападение львов на огромного буйвола. Сначала львы окружили животное, и когда буйвол почувствовал опасность, было уже поздно. Львы тщательно спланировали атаку и начали нападать на него сзади, пока он пытался убежать. По словам туриста-автора видео, гид сказал, что "никогда не видел ничего подобного".
Понравилась атака льва.
Делаю игрулю на Playdate на чистом C. Глава 3
Я пишу игру на игровую консоль Playdate на чистом C. Игра в жанре "выживальщик" наподобие Vampire Survivors. Так как в чистом C отсутствуют многие современные объектно-ориентированные удобства мне приходится по-всякому изворачиваться чтобы адаптировать свои идеи в код. В этих заметках ты узнаешь из первых уст как создаётся игруля с нуля от идеи до публикации.
В прошлой главе я описал как инициализирую сцену, как очищаю ресурсы, показал как заполняю сцену реквизитом и даже поэкспериментировал с генерацией этого самого реквизита. В этой главе я расскажу как работает самая важная функция GameUpdate, в частности, обработка ввода и процессинг данных.
GameUpdate это функция-колбэк, которая вызывается каждый тик. А значит её задача это реализовать святую троицу любой игры:
считать ввод от игрока
обновить состояние игры
отрисовать обновлённое состояние игры.
Если ты когда-нибудь был на собесе в геймдев контору, то 90% вероятность, что у тебя спрашивали про эти три шага. А ещё если ты когда-нибудь писал код для Arduino, то ты должно быть помнишь две функции, которые там всегда должны быть: setup и loop. Вот GameUpdate это как раз аналог loop.
На сцене есть машина, которая двигается от нажатия крестовины, и есть реквизит: кактусы, песчаные насыпи и перекати-поле. Перекати-поле двигается прям как в жизни. То есть, оно меняет позицию по-горизонтали (по оси X) и ещё прыгает вверх-вниз как бы отскакивая от земли. Чтобы реализовать движение нам нужно совладать со временем. Для этого нам в каждом тике нужно знать сколько точно прошло времени с прошлого тика. Из коробки этого параметра в событии Update нет, однако мы можем этот параметр высчитать при помощи функции API playdateApi->system->getElapsedTime();. Эта функция возвращает количество секунд прошедших с момента запуска игрули. Это не разница в тиках, но уже что-то. Для разницы времени в тиках надо ещё знать значение полученное из той же функции в прошлый тик. Потому в структуре Game есть поле float previousElapsedTime;. В конце функции GameUpdate мы сохраняем в это поле результат вызова getElapsedTime, а в начале GameUpdate мы вычитаем разницу между нынешним значением getElapsedTime и previousElapsedTime. Это значение и есть тот самый dt, который равняется количеству секунд прошедших с прошлого тика. Так как на старте игры в файле main.c в первой главе я установил FPS равный 30, то в среднем dt у меня равен 0.033 секунд.
Далее мы процессим инпут - собираем значения нажатых кнопок и в зависимости от них обновляем данные.
PDButtons это битовая маска объявленая в Playdate SDK. Битовые маски в сишке реализуются либо как enum, либо просто как int в отличие от Свифта, где битовая маска это совершенно иной особый класс данных.
Битовая маска PDButtons содержит в себе список нажатых и ненажатых кнопок консоли.
Ещё, возможно, у тебя есть вопрос что за такая функция PlayerVehicleAngleCreateFromButtons на строке 72. Это способ определения одного из восьми направлений машины имея на руках нажатые кнопки девайса:
Зачем нужен параметр oldValue в ней? Дело в том, что нам надо что-то возвращать даже если ни одна кнопка не нажата. А что вернуть если ни одна кнопка не нажата? Какое направление? В Свифте/C++/C# я бы вернул зануляемое значение (Optional в Свифте, std::optional в C++ и Nullable в C#), но в сишке это не так удобно потому что нет дженериков/шаблонов, потому я решил передавать старое значение направления потому что в случае когда ни одна кнопка не нажата направление машины просто не меняется. Это логично потому что в жизни если ты не трогаешь руль, то направление автомобиля тоже не меняется. Вот потому мы передаём старое значение и возвращаем его тогда, когда в Свифте/C++/C# вернули бы null. Если бы я работал в корпорации с отжайл-митами, ретроспективами, эффективными менеджерами, тимбилдингами и код-ревью, то обязательно появился бы ревьювер, который мне рассказал, что аргумент oldValue, если посмотреть на ситуацию под определённым углом, переносит логику того как движется машина внутрь функции PlayerVehicleAngleCreateFromButtons, а это неправильно потому что если следовать SOLID, стремиться писать идеальный код, утром и вечером чистить зубы, ходить на йогу, участвовать в городских марафонах, отказаться от мяса, глютена, молока, сахара, соли, глутамата натрия и кока-колы, то эта функция должна отвечать исключительно за создание инстанса перечисления PlayerVehicleAngle и больше ничего, а логика передачи старого значения обязательно, прям кровь из носу, век воли не видать, без разговоров, обсуждений и переговоров, должна находиться за пределами функции PlayerVehicleAngleCreateFromButtons потому что чисто теоретически у нас может эта функция использоваться не только для машины, а для чего-либо другого, что имеет также 8 направлений, но в случае если ничего не нажато направление будет, например, сбрасываться вверх. И пофиг ревьюверу на то, что такое случится примерно когда рак на горе свистнет, в четверг после дождя и ровно в следующую секунду после второго пришествия.
Если отбросить иронию и сформулировать ответ для занудного воображаемого ревьювера, то он (ответ) будет таким: значение oldValue это прекрасный подход реализации кода очень схожий с построением электрических цепей. Значение словно ток по цепи идёт сквозь функцию, и при определённых условиях оно может измениться на выходе, а может остаться таким же. Вообще код в стиле электрических цепей популярен в сишке, и при этом не так популярен в объектно-ориентированных языках. Я, понятное дело, не призываю всех на сишке писать именно в такой парадигме, но за себя я отвечаю вот таким вот образом.
Фух. Далее. Есть ещё функция GameAnyArrowIsPressed. Она возвращает 1 если хотя бы одна кнопка на крестовине нажата и 0 в противном случае:
Реализация функции GameAnyArrowIsPressed (возможно, следовало разнести операторы & по отдельным строкам для красоты кода)
Штош, мы пришли к следующему невероятно важному шагу нашего жизненно-важного тика - обработка перекати-поле.
Констанцията screenSize пока не нужна - она пригодится позже. Далее мы проходимся по массиву старым проверенным методом: достаём количество объектов в нём и описываем цикл for. Получив очередной объект перекати-поля на строке 118 я готов его менять (потому указатель на Tumbleweed неконстантный). Разминаю руки и говорю себе "делай красиво!". Первым делом процессим позицию потому что перекати-поле перекатывается по полю по-горизонтали. Каждый тик двигающийся объект сдвигается (вот это поворот!), а значит мы должны проделать нехитрые манипуляции с позицией. Это требует базовые знания раздела механики из физики (того самого про "скорость равняется пройденный пуць делить на время", а ещё я обожаю слово "пуць" которое я подцепил в Беларуси когда жил там три года. Я стараюсь говорить "пуць" везде вместо слова "путь" и рекомендую тебе тоже так делать так как от такого русский язык станет только краше!). Чтобы лучше понять как происходит процесс движения в коде в первую очередь надо понять что нам нужно в конце концов сделать за тик. За тик нам нужно изменить позицию каждого объекта перекати-поле. Точнее, понять насколько изменилась позиция объекта перекати-поле относительно старой позиции за тик. Это изменение как раз хранится в константе dTumbleweedPosition, которая создаётся на строке 121. Высчитывается она очень просто: скорость перекати-поле умножается на dt, то есть, скорость умножаем на прошедшее время за один тик. А далее изменение позиции dTumbleweedPosition просто прибавляется к позиции этого же перекати-поле.
Подобным образом движение работает у всего вообще везде, не только в моей игре, а во всех играх и не только играх - всякие плавно двигающиеся кнопки в пользовательском интерфейсе, прыгающая иконка загрузки в яблочном браузере Safari, всплывающее окно антивируса Avast, падающие пуш-уведомления на iOS и многое другое что можно перечислять тут ещё до полуночи.
Окэй, с движением мы разобрались. Идём далее. А далее мы процессим прыжок. Дело в том, что перекати-поле подпрыгивает в движении. Значит нам в нашем мире который мы создаём своей мыслью и кодом нужно запрограммировать аналогичные прыжки перекати-поле и желательно чтобы результат выглядел правдоподобно, а не топорно как анимация в Героях 4. Вот только как сделать подпрыгивания чтобы они выглядели достаточно правдоподобно? Просто линейно как движение? Но это будет обсосно так как в реальности в любом движении по-вертикали участвует ускорение свободного падения, а это делает функцию движения квадратичной, а значит линейное движение не подойдёт. Функция нужна точно квадратичная, то есть, аргумент в ней обязательно должен хотя бы в одном месте возводиться в квадрат. Самое банальное это парабола. Она самая подходящая тут потому что в реальной жизни всё падает по параболе (конечно если игнорировать ветер и вообще если экспериментировать на сферических цыплятах в вакууме). Но если перекати поле будет лениво лететь в сторону земли по параболе, то тогда при столкновении с землёй мне надо будет иметь реализованную логику этого самого столкновения для отскока. Тут я оценил-взвесил прям как Экшон-мэн (помнишь такого супергероя? я в детстве обожал мультик "Экшон-мэн", и особенно мне нравилась его трёхмерная компьютерная рисовка. Тогда мне казалось, что это лучшая графика на свете. Недавно я решил пересмотреть этот мультик и офигел от того какая оказывается ужасная графика там на самом деле! RDR2 меня разбаловала! В общем, Экшон-мэн в момент кульминации каждой серии произносил "оценить, взвесить", просчитывал свои движения до мельчайшей точности, а в следующие 10 секунд нагибал всех врагов ультой) и решил сделать проще: я использую уравнение окружности, точнее, уравнение косинуса (или суниса если угодно, потому что график синуса это график косинуса сдвинутый на 90 градусов).
Вот только для наших целей мы график синуса чуть модернизируем - засунем его в модуль. Засовывание любой функции в модуль делает с её визуальным отображением занимательный фокус - отображает нижнюю половину вверх словно ось x превратилась в зеркало.
И вот такой вариант прям идеально похож на траекторию движения перекати-поля, и при этом нам не нужно писать логику столкновения с землёй и последующего отскока. Это тот редкий случай когда та фигня, которой тебя пичкали в школе, тебе пригодилась в работе!
Для адаптации данного математического фокуса в код нам нужно чтобы каждый объект перекати-поля имел значение "поворота" прыжка, а также скорость этого поворота (на сколько радиан значение поворота изменится за 1 секунду). Почему поворот? Потому что график синуса принимает в качестве переменной именно направление. Для пущего понимания на это можно смотреть как на фазу, которая крутится. Таким образом, у структуры Tumbleweed есть поля jumpVelocity и jumpAngle. На строке 125 мы высчитываем значение dTumbleweedJumpAngle равное количеству радиан на которое изменился jumpAngle, на строке 126 прибавляем это значение к jumpAngle, а на строке 127 нормализуем jumpAngle. Нормализация направлений это вещь, которую иногда следует делать если работаешь с направлениями - примерно как убирать какашки за кошкой если ты живёшь с кошкой (или она с тобой, лол). Так как значение направления циклично (0 радиан и 2*π радиан это одно и то же значение, например), можно для чистоты кода, совести и кредитной истории после операций над направлением приводить его в диапазон [0; 2*π) если вдруг это направление вышло за пределы (если кошка покакала мимо лотка надо всё вытереть, потому что сама кошка это вряд ли сделает).
Вообще будь у нас С++ я, возможно, нормализацию бы засунул прям внутрь класса Angle в оператор присваивания, который можно невозбранно перегружать. А может и нет - неявности порой делают код хуже. Как бы там ни было, именно таким образом мы процессим прыжки перекати-поля.
Итого, мы разобрались с процессингом позиции перекати-поля, прыжков (на самом деле процессить прыжки это лишь полдела, надо ещё их кошерно отрисовать, а это я покажу далее), осталось запроцесить кадр. Да, перекати-поле в моей игруле имеют несколько кадров для красивости. Я так сделал так как иначе если бы у перекати-поля был бы один кадр это выглядело бы обсосно. А я не хочу чтобы моя игруля выглядела обсосно. Вот для процессинга кадра я в структуру Tumbleweed добавил поле frameIndex. Вообще в игре у много чего будет такое поле и подобная логика. Ну и скорость изменения frameIndex тоже есть: это поле frameIndexVelocity. Да, это поле есть у каждого объекта Tumbleweed, хотя у всех объектов оно имеет одинаковое значение. Можно было бы не добавлять это поле потому что вроде как оно избыточно, но пусть будет - вдруг я решу сделать скорость разной у разных инстансов перекати-поля (а такие мысли в момент написания кода у меня были), а экономить память на спичках это путь в сумасшедший дом. Всего кадров у перекати-поля сделано 4. В одной из прошлых глав ты видел константу TumbleweedSpritesCount = 4 - вот это про это. frameIndex - это число с плавающей точкой, которое меняется в диапазоне [0; 4) со скорость указанной в frameIndexVelocity. Логика строк 130 - 134 осуществляет именно это.
Вот так устроен процессинг перекати-поля. Как тебе? Меня лично вставляет. Идём дальше.
Порой надо создавать перекати-поле, а не только процесить. Для этого надо решить по какой логике оно будет создаваться. Когда я усердно играл в Minecraft я частенько читал вики по нему. И в вики по Майнкрафту рассказывали каким образом спаунятся различные сущности. И логика спауна примерно такая: шанс один из десяти тысяч что в конкретном тике заспаунится сущность. Вот такую же логику я решил впиндюрить потому что это просто и понятно.
Строка 139 говорит нам, что с шансом 1 к 100 (tumbleweedSpawnChangePercentage равна 1) создастся новое перекати-поле в тике. На строке 154 создаётся инстанс перкати-поля функцией TumbleweedCreate, а на следующей строке этот инстанс отправляется (на самом деле копируется) в массив game->tumbleweeds.
Для создания перекати-поле нам нужно 4 аргумента: позиция на карте, скорость передвижения, скорость подпрыгивания и скорость изменения кадра. Позиция на карте высчитывается суперхитрым образом - новое перекати-поле появляется просто ровно за границей экрана левой либо правой. И едет в сторону машины игрока по-горизонтали. Можно, конечно спаунить "по-честному" в случайной точке достаточно большого игрового поля, но тогда игрок просто будет редко видеть перекати-поле, особенно в начале игры, а это ухудшает пользовательский опыт. Скорость подпрыгивания это количество радиан прошедших за секунду для значения от которого мы считаем синус график которого я ранее показывал. А про скорость изменения кадра ты уже и так знаешь: у перекати-поля 4 кадра, как я говорил ранее, и их надо с определённой скоростью менять.
Далее на строке 158 смещению камеры присваивается позиция машины чтобы машина всегда была в центре экрана куда бы она не ехала. А на строке 160 вызывается функция GameDraw, которая весь описываемый мной тут балаган отрисовывает чтобы игрок видел что происходит, иначе зачем всё это?
Отрисовку мы рассмотрим в следующей главе, а тебе спасибо что дочитал. Если нравится как я пишу и хочешь меня поддержать денюшкой, то я есть на патреоне и бусти.
UPD:
Индия ч.7. Кхаджурахо
Кхаджурахо, о котором я абсолютно ничего не знала до момента прибытия в него, оказался знаменит прекрасно сохранившимися храмами со сценами из Камасутры.
Это очень приятный городок. Здесь много белых туристов, из разных стран. Местное население к ним привыкло и чувствуешь здесь себя относительно комфортно. Тем не менее, это не отменяет обязанности белого туриста фоткаться со всеми местными.
Город по индийским меркам чистый.
![](https://cs14.pikabu.ru/post_img/2024/04/15/11/1713208770145035271.jpg)
![](https://cs14.pikabu.ru/post_img/2024/04/15/11/1713208770147338611.jpg)
![](https://cs14.pikabu.ru/post_img/2024/04/15/11/1713208770150154946.jpg)
![](https://cs13.pikabu.ru/post_img/2024/04/15/11/1713208771141113028.jpg)
![](https://cs13.pikabu.ru/post_img/2024/04/15/11/1713207326155635622.jpg)
Утром я пошла в главный комплекс храмов, вход в который платный, 600 рупий. Билет нужно купить онлайн, русские карты не проходят. Хозяин гостиницы порывался купить мне билет, но я попросила приятеля с казахской картой.
Офигела от комплекса. Здесь стриженные газоны, клумбы с цветами и кафе, в котором есть почти настоящий капучино. Первый раз встречаю такое в Индии, до этого было все такое, грязноватенькое.
Эту лепнину можно часами рассматривать. Она потрясающая. Это та Индия, которую я ожидала увидеть. Из рассказов про Маугли. Я хожу, трогаю все и разглядываю. Как они могли это сделать?! Спойлер - я потом ещё доехала до пещер Эллоры и Аджанты и там охренела от упорства древних индусов окончательно.
![](https://cs14.pikabu.ru/post_img/2024/04/15/11/17132076381588058.jpg)
![](https://cs13.pikabu.ru/post_img/2024/04/15/11/1713207640179321116.jpg)
![](https://cs13.pikabu.ru/post_img/2024/04/15/11/1713207640168089182.jpg)
![](https://cs14.pikabu.ru/post_img/2024/04/15/11/1713207642137651895.jpg)
![](https://cs14.pikabu.ru/post_img/2024/04/15/11/1713207643132678207.jpg)
![](https://cs14.pikabu.ru/post_img/2024/04/15/11/171320764418595830.jpg)
И конечно сцены из камасутры. Такие затейники были древние индусы.
![](https://cs13.pikabu.ru/post_img/2024/04/15/11/171320789618809487.jpg)
![](https://cs14.pikabu.ru/post_img/2024/04/15/11/171320787415047488.jpg)
![](https://cs13.pikabu.ru/post_img/2024/04/15/11/171320787914866337.jpg)
![](https://cs14.pikabu.ru/post_img/2024/04/15/11/171320787411973092.jpg)
![](https://cs13.pikabu.ru/post_img/2024/04/15/11/1713207884139328865.jpg)
![](https://cs14.pikabu.ru/post_img/2024/04/15/11/1713207886150541555.jpg)
![](https://cs13.pikabu.ru/post_img/2024/04/15/11/1713207888127911769.jpg)
![](https://cs14.pikabu.ru/post_img/2024/04/15/11/1713207894179662573.jpg)
![](https://cs14.pikabu.ru/post_img/2024/04/15/11/1713207897173036089.jpg)
![](https://cs13.pikabu.ru/post_img/2024/04/15/11/1713207899138117301.jpg)
![](https://cs14.pikabu.ru/post_img/2024/04/15/11/171320790012165063.jpg)
![](https://cs14.pikabu.ru/post_img/2024/04/15/11/1713207901151587164.jpg)
![](https://cs14.pikabu.ru/post_img/2024/04/15/11/1713207902177098905.jpg)
Гиды подсвечивают лазерными указками в самые развратные сцены, индусы-туристы краснеют и хихикают, белые туристы разглядывают с интересом, все счастливы.
Отмахиваюсь от желающих сделать со мной селфи. Но в один момент они меня облепляют, загоняют в угол и мне приходится делать с ними 100500 фото. Один парень выкладывает наше совместное фото в инсту и подписывает типа - "спасибо за прекрасные моменты, проведенные вместе". Ржу. Я даже не знаю его имени 😂😂😂 до сих пор на меня там подписан.
Они достали меня со своими селфи. Я хочу ходить и разглядывать лепнину, а не вот это вот все. Сфоткаешься с одним - тут же выстраивается очередь из других желающих. Сажусь на лавочку, полюбоваться огромным деревом, просто садятся рядом и начинают фоткаться. Чувствую себя обезьянкой на набережной Анапы в разгар туристического сезона.
Вечером хозяин отеля сообщает мне, что несколько постояльцев едут завтра на экскурсию по остальным храмам и предлагает мне присоединиться, соглашаюсь.
Вход в другие храмы бесплатный, но они в отдалении друг от друга, и чтобы их посмотреть, лучше взять туктук. На троих у нас получилось 450рупий/человека за водителя и гида.
Всего в Кхаджурахо 82 храма было. Сейчас осталось 23, остальные разрушены.
Гид рассказывает историю храмов и символы, но я почти ничего не запоминаю. Так ли это важно, это просто невероятно красиво. Можно бесконечно смотреть.