Австралийское сафари (Playdate)
4 поста
4 поста
15 постов
Я вспомнил ещё до пандемии, лет 10 назад когда я был фрилансером программистом, я решился работать дома удалённо чтобы экономить на аренде места в коворкинге. Моей семье (тогда я только закончил универ и жил с родителями) я объявил, что буду сидеть дома пока они на работе, и в рабочее время прошу меня не беспокоить, так как я тоже работаю, просто делаю это дома. Бабушке, которая жила в 10 минутах от нас, я это так же передал, и все окнули такой расклад.
С первой же недели началась ебалá: ой, сходи в магаз, купи вон то, сходи к бабушке - принеси ей то, ты же всё равно дома работаешь, тебе же не сложно. И так каждый день! Раньше всех этих дел не было, а как я начал работать дома эти дела внезапно объявились.
Я пытался объяснить, что лучше меня не отвлекать в рабочее время так как я на работе - все кивали, но по факту это ничего не меняло. Работать нормально дома не получалось.
В итоге, я вернулся в коворкинг, и дела-просьбы вдруг пропали, а я снова получил возможность работать без отвлеканий.
Не так давно (год назад на самом деле) я приобрёл необычную игровую консоль Playdate.
Она такая маленькая, жёлтая и имеет крутилку (крэнк или иногда в дословном переводе с испанского кривошип). Ах да, у неё еще экран монохромный. Не чёрно-белый - чёрно-белый экран умеет показывать оттенки серого
- монохромный умеет показывать только чёрное и белое. Точнее, не совсем белое, а что-то что не чёрное.
Playdate это неповторимая смесь примитивизма и современных технологий. На первый взгляд можно подумать «ну и кто в такое играет?». Однако я щас без преувеличений скажу, что уже для Playdate сделано более 800 игр. То есть, в отличие от миллиона неизвестных консолей, которые сегодня создаются для тех, кто вспоминает детство за сегой и дэнди, пардон, нинтендой, у Playdate реально есть активное сообщество.
Какую игру я хотел сделать? Примитивные головоломки я отбросил сразу же. Хотелось сделать что-то драйвовое, чтобы прям был экшон как в GTA - машины там, физика, стрельба. Значит, нам нужна машина! Понятное дело, игра будет двухмерная. Если машина, значит, она должна ездить, и желательно не с идиотским видом сверху как в GTA2 (справедливости ради уточню, что я прошёл всю GTA2, и посмотреть это можно на ютубчике), а чтобы была перспектива, чтобы было красиво. Так как никакого 3D не ожидается, а для управления у нас есть крестовина (D-Pad на английском). Значит, нам нужна машинка в восьми направлениях. Дав задачу своей художнице я получил вот такое:
"Так, стоп, а какой сеттинг у игрули?" спросишь ты. Сеттинг простой - мы катаемся на внедорожнике вооружённым пулемётом по пустыни в Австралии и, как настоящие любители дикой природы, отстреливаем живность: эму и кенгуру. Справедливости ради уточню, что живность нас тоже пытается грохнуть. То есть, жанр игры "выживальщик" наподобие Vampire Survivors.
Так как у Playdate только два цвета, нам нужно постараться чтобы создать ощущение нахожения в пустыне. Потому сначала сконцентрируемся на реквизите, который нас будет окружать. Конечно же это кактусы, небольшие песчаные насыпи и перекати-поле.
Ах-да, забыл упомянуть: размер экрана у нас 400 на 240 пикселей. То есть, ну очень маленький. Значит, объектов на экране должно быть минимум чтобы понять что происходит.
Перейдём к самому вкусному - к коду.
На Playdate официально можно разрабатывать на двух языках программирования: C и Lua. Так как Lua я не переношу как и все скриптовые языки (я лично за С++ во всех нормальных играх), значит будет сишка. Не сказать, что я фанат сишки, но это лучше луы. А что делать с отсутствием объектно ориентированного программирования? Будем симулировать и выкручиваться по возможности, потому что 10 лет работы на ООП языках (Swift, C++, C#) чётко отформатировали мою голову под объектно-ориентированное мышление.
Первый шаг в написании игры это файл main.c, в котором нет функции int main, зато есть системный колбэк ("обратный вызов" или "звони назад")
int eventHandler(PlaydateAPI* playdate, PDSystemEvent event, uint32_t arg).
Эта функция это единственная прослойка между Playdate и моим кодом. Она вызывается на любой "чих", точнее, событие. Первый аргумент PlaydateAPI* playdate это указатель на непосредственно API операционной системы девайса. PlaydateAPI это структура, которая состоит из структур, которые хранят сишные указатели на большое количество функций (нарисовать, что-то, открыть файл, показать fps и т.д.). Второй аргумент это наш тип "чиха", точнее, события:
На третий аргумент arg пока пофиг - он нам не нужен.
Код игры можно воткнуть прям в файл main.c, но я так не хочу. Не потому что это считается зашкварно - то что как считается это вещи очень субъективные, и вряд ли они когда-то меня останавливали от самых сумасшедших вещей в коде. Я вынесу код отдельно потому что я хочу чтобы он был распределён красиво и удобно, модульно, но не слишком. То есть, чтобы лично мне было понятно где что искать, но чтобы не упарываться в оформление структуры ради оформления структуры как это делают Java-разработчики. Потому вся логика катания машины по пустыне будет аккуратно сложена в файл с супербанальным названием Game.
Game будет имитировать класс, он будет создан при получении события о старте игры, и будет удаляться в событии об окончании игры. А указатель на этот объект будет храниться где? Правильно: в статичной памяти.
То есть, игра создаётся в событии `Init` (строка 21), потом вызывается у игры функция GameSetup (строка 22) для единоразовых стартовых действий опосля создания (тут можно поспорить, что эти вещи можно сделать в той же функции GameCreate, но спор оставим тем, кто любит спорить вместо написания кода). Далее я прикручиваю вызов функции GameUpdate к тику игры. Напрямую я это сделать не могу так как функция обновления имеет сигнатуру int (*)(void *), а мне нужно int (*)(Game *), потому я создаю функцию-прослойку rawUpdate, которая принимает void *userData, кастит его в указатель на Game и руками вызывает GameUpdate.
Отлично, с мэйном всё понятно. Теперь давай глянем что есть в самом Game. Но сначала позволь проспойлерить и показать что получилось чтобы ты не зевал от кода.
Откатимся назад в прошлое. Сишка кажется нормальным языком, но ровно до того момента, когда тебе нужно работать с динамическими объектами: строками и массивами. Оказывается, что чтобы передать массив в функцию нужно иметь два аргумента: указатель на данные и целое число равное количеству объектов, лежащих по тому самому указателю один за другим в памяти как поезд. Ну либо можно хранить объекты в статичной памяти, там всё проще - объявил статичный массив и пользуйся. Одно но - у статичного массива константный размер, и этот размер должен быть известен в момент компиляции. Чем это чревато? Тем, что если ты объявил массив, скажем, кактусов, размером, скажем, 100 штук, это значит, что в игре 101 и более кактус быть уже не может. И так с любым статичным массивом.
Забавный факт: когда несколько лет назад слили исходники GTA3 и GTA Vice City там динамические объекты (машины, пешеходы, пикапы (броня, спрятанные пакеты, буйства, оружия, деньги)) как раз хранились в статичных массивах. И количество пикапов, например, ограничивалось числом 512. То есть, если в игре устроить заварушку чтобы вокруг валялось много денег, оружия и прочих пикапов в количестве 512, то при появлении нового один старый пикап тут же будет пропадать даже если ему ещё рано пропадать (деньги и выпавшее из врагов оружие пропадают по таймауту).
Вот я так не хочу. А хочу я чтобы у меня был один объект массива без отдельно указателя на данные и отдельно размера, потому что если таскать везде два аргумента представляя что это один, это верный способ свихнуться. А если мне нужно два массива - будет два объекта. А все детали (указатель на данные, размер, прочее) должны быть аккуратно спрятаны внутри, как это сделано в ООП языках. В С++ для таких целей есть std::vector, в Swift - Array, в C# - List. В сишке ничего такого нет, значит надо придумать!
Долго томить не буду, вот что получилось:
Заголовочный файл имеет предобъявление структуры Array, которой по факту не существует, и API для создания, взаимодействия и уничтожения массива. Важная деталь: так как это сишка у нас нет деструкторов как в С++/C# или deinit-функций как в Свифте, которые автоматически вызываются когда область видимости массива заканчивается. Значит, нам надо вызывать функцию-деструктор руками. То есть, на каждый вызов ArrayCreate где-то должен быть один вызов ArrayDestroy. А что будет если забыть вызвать ArrayDestroy? Правильно: утечка памяти. Я чувствую себя программистом-дауншифтером. Но я сам так решил: начал танцевать с дьяволом - жди окончание песни.
Теперь давай я покажу тебе реализацию:
Тут у нас есть структура ArrayImpl. И мы в функции ArrayCreate создаём именно инстанс структуры ArrayImpl, а не Array (та самая несуществующая структура), однако указатель на созданные данные нагло кастим в указатель на Array. Зачем так делать? Честно скажу, я это подглядел у команды SQLite в исходном коде SQLite. Таким образом мы разделяем интерфейс и прячем реализацию, то есть, делаем её приватной в языке программирования, где приватности нет (я про слово private говорю, которое есть чуть ли не в каждом известном мной объектно-ориентированном языке программирование, за исключением Свифта самой первой версии - там уровни доступа не сразу завезли). Логика в том, что весь API массива принимает указатель на Array, а внутри этот указатель кастуется в указатель на ArrayImpl, который хранит реальные данные нашего массива.
А, кстати, что же хранит ArrayImpl? На последнем скриншоте мы видим, что там не два поля как это бывает у сишного массива, а больше:
1) int itemSize - это размер одного хранимого объекта в байтах. Этот размер нужен чтобы знать сколько байт выделять когда мы пытаемся засунуть в массив один объект (push_back в векторе на C++, append в Свифте у массива и Add в C# у листа). Ты возразишь "но ведь С++ вектор не хранит это поле, значит можно в нашем случае тоже как-то его избежать!". Однако, С++ на самом деле хранит это поле, просто не в виде явного члена класса, а в качестве параметра шаблона: std::vector<T> имеет параметр шаблона T, от которого в любой функции внутри класса std::vector можно вызвать sizeof(T) и получить заветный размер одного объекта. Однако в сишке шаблонов нет. Вот прям совсем нет. Примерно как нет воздуха на Луне. Потому приходится передавать один дополнительный параметр int itemSize, который позволит нам во время жизни массива знать размер одного элемента. Нет, конечно в сишке есть макросы, которые при достаточной сноровке можно использовать как шаблоны, однако я имею аллергию на макросы, так что макросов не будет.
2) Указатель на функцию realloc. Это может выглядеть избыточно, и по факту так оно и есть, однако у PlaydateAPI (помнишь, я тебе в файле main.c показывал указатель на такую структуру?) есть свой указатель на функцию realloc, который, как мне подсказывает мой копчик, равен системному вызову realloc из стандартной библиотеки Си, и который можно вызвать вот так playdateApi->system->realloc. То есть, нам разработчики намекают использовать их realloc вместо системного. Что ж, это нетрудно. А вторая причина - передача функции realloc по указателю позволяет покрыть вызовы этой функции юнит-тестами, то есть, сделать мок или прокси этой функции, и это невероятно удобно. Правда, я до сих пор не покрыл массив юнит-тестами, но когда-нибудь я обязательно это сделаю, честно-честно!
3) void *data это непосредственно указатель на данные. Почему именно указатель на void? Потому что массив по своей природе универсален: он способен хранить и int'ы, и кастомные структуры, а значит нужен указатель какого-то общего типа, что-то вроде object в C# или AnyObject в Свифте. И тут сишка нам щедро предлагает указатель в пустоту. Любой сишный прогер знает, что указатель на void это произвольные данные. В нашем случае это данные массива. Массив может хранить N объектов, а значит в этом состоянии у него указатель data будет указывать на кусок памяти в минимум N * itemSize байт идущих подряд если только N не равен 0. А если массив пустой, то data равен NULL.
4) int capacity это ёмкость данных. Ёмкость равняется количеству объектов, которые умещаются в объём выделенных данных, которые лежат по адресу data.
5) int size это количество реальных объектов, которые лежат по адресу data. "В чём отличие size от capacity?" спросишь ты. Тут логика та же, что и у std::vector в C++ - capacity в некоторых случаях может отличаться от size. Например, если в массиве было 4 объекта, и мы один объект удалили чтобы осталось 3, мы не будет выделять новый участок памяти под 3 элемента, а старый освобождать. Мы просто уменьшим size, но оставим capacity как есть. Это, во-первых, быстрее, чем перераспределять память (особенно если в массиве лежит больше тысячи объектов), во-вторых, если после удаления мы решим снова добавить объект, то память вновь не придётся перераспределять, так как ёмкости массива хватит. Да. безусловно если помимо этого одного мы добавим ещё один, то придётся осуществить перераспределение чтобы выделить больший кусок памяти, но тут мы уже ничего сделать не сможем, кроме как заранее оптимизировать пытаясь предсказать какая ёмкость лучше. Но это уже не ответственность массива - это ответственность того, кто этим массивом пользуется.
Итого я расписал как устроена внутрянка массива. Не будем забывать мою цель - мне нужен аналог std::vector из С++ или Array из Свифта удобный настолько, насколько это возможно в сишке. Так что давай я покажу как устроен API у массива.
1) ArrayCreate (на прошлом скриншоте есть) - эта функция создаёт объект массива. Она принимает itemSize и указатель на функцию realloc. Задача функции - выделить память под ArrayImpl, присвоить все стартовые поля ему и вернуть созданный указатель, но в качестве Array*, а не ArrayImpl*. Количество объектов у только что созданного массива всегда равно нулю. Возможности создавать массив из литерала как в С++ (auto myArray = {1, 2, 3}), C# (var myArray = new int[]{ 1, 2, 3 }), Swift (let myArray = [1, 2, 3]) и даже Objective-C (NSArray *myArray = @[@1, @2, @3]) нет, так как это просто синтаксический сахар над несколькими операциями (создание и заполнение), и негоже такое в сишке иметь.
2) ArrayClear - функция очистки массива.
Это не уничтожения массива, а именно опустошение хранилища если количество хранящихся объектов в массиве больше нуля. Название я полностью взял с std::vector::clear из С++. Можно было взять removeAll из Свифта, но к clear я больше привык. Суть функции: мы берём полученный аргумент и кастуем его в указатель на ArrayImpl. Если data у полученного объекта не равна нулю, т.е. если массив непустой, то мы дропаем дату и запоминаем, что capacity и size равны нулю. Ну а если массив и так пустой, то мы не производим никаких операций с памятью.
3) ArrayGetSize - самая простая функция, которая возвращает размер массива.
Просто кастуем указатель и возвращаем хранящееся значение size.
4) ArrayGetObjectAt - получение объекта. В "нормальных" языках у нас есть оператор "квадратные скобки", а тут у нас сишка, так что любое действие это просто функция.
Функция возвращает адрес, то есть, указатель на нужный объект по указанному индексу в массиве. Так как храниться может внутри что угодно, то возвращаем мы уже известный нам указатель на void. А задача клиента будет уже скастовать этот указатель в указатель правильного типа: если в массиве лежат int'ы, то надо будет скастовать в int, если float - то во float, если кастомная структура или union - ну ты понел. И тут, понятное дело, можно легко спутать тип потому что мы люди, а люди ошибаются. Как страхуются от таких проблем в других языках? В С++ всё так же шаблонами: если std::vector<T> имеет T равный int, то и operator[] будет возвращать T и только T. В Свифте то же самое, только там не шаблоны, а дженерики - шаблоны на минималках, в C# тоже дженерики. А в сишке мы дауншифтим, смирись с этим и не выпендривайся! Ах да, если индекс переданный в функцию оказался за пределами доступных объектов (меньше нуля или больше либо равен размеру массива), то мы просто вернём нулевой указатель. В троице моих упомянутых выше языков в этом случае бросается исключение, но в сишке исключений нет, да и как по мне без исключений код приятнее, потому что исключения это тот же оператор goto, от которого нас так яростно отучивали 20 лет назад. Потому мы возвращаем NULL. А если индекс валиден, то мы хитрой арифметикой указателей вычисляем правильный адрес и возвращаем его.
5) ArrayGetMutableObjectAt - это копия прошлой функции, но возвращающая неконстантный указатель на объект. Почему это важно выделить в отдельную функцию?
Язык у нас, конечно, не самого высокого уровня, однако константность в нём есть полноценная, а константность это штука, которая невероятно повышает читаемость кода (я особенно привык объявлять константы вместо переменных во время работы на Свифте, а потом когда в плюсовом проекте везде втыкаю const иногда встречаю возмущенные ревью "ну и нахуа ты везде свой бесполезный const понапихал 🗿"), однако константы почему-то максимально игнорируются программистами на сишке, что я лично не одобряю никак.
6) ArrayPushBack - добавление объекта в массив. Аналог std::vector::push_back из C++, Array.append из Свифта и List.Add их C#.
Это самая навороченная по логике функция массива. Объект передаёт константным указателем на void. Тут нам сначала нужно проверить умещается ли новый объект в уже имеющуюся ёмкость (capacity). Если умещается, то мы просто копируем его в data со сдвигом равным старому размеру помноженному на размер объекта (itemSize), а количество байт для копирования тоже равны размеру объекта (itemSize). Важно при вызове ArrayPushBack передавать адрес объекта, а не сам объект, а то будет ошибка: ArrayPushBack(myArray, &myValue). Неудобно, согласен, зато универсально, потому что в таком массиве можно хранить и структуры, и базовые типы.
7) ArrayDestroy - последняя на сегодня функция массива. Эта функция, как ты уже знаешь, уничтожает массив, то есть чистит его из памяти. Любой массив рано или поздно окажется тут, где закончится его путешествие по этому бренной жизни, точнее, по материнской плате. Эта функция это Вальгалла всех массивов. В неё мечтает попасть каждый массив, а те, кто не попадают, те остаются болтаться в утекшей памяти.
Тело функции крайне банально: сначала вызываем ArrayClear чтобы почистить объекты если они есть, а далее дропаем массив из памяти словно он никогда и не существовал.
Заключение
Признаюсь, стилистику имитации объектов я взял в CoreFoundation - это такая сишная либа от Эпла, которая имеет API очень похожий на Objective-C, на основе которого позже появился Swift. Это, кстати, не единственный способ имитации ООП в сишке - ещё я пользовался либой GTK+, но там API немного отличается, в частности, там всё обмазано макросами, а на них у меня аллергия.
"Сёгун" Джеймса Клавелла. Я ее прочел чуть ли не разом получая удовольствие буквально от каждой главы. Сюжет происходит в 1600-м году в феодальной Японии. Туда прибыл корабль с европейцами, в том числе, с главным героем, который без своего желания оказывается втянут в политические игры страны. Огромное впечатление на меня произвела разница в менталитете и культуре между европейцами и японцами. Европейцы показаны чумазыми варварами, которые боятся воды и не моются, потому что "от воды простывают и умирают", в то время как в Японии цивилизация: люди принимают ванну, разуваются на пороге при входе домой, девушки используют косметику, а еще у них есть книга поз для секса, который считается чем-то плохим среди европейцев. В этом плане книга похожа на "Шантарам": белый человек оказывается в совершенно иной культуре и проникается ей настолько, что уже не может представить себя вне её.
Чтобы замотивировать вас прочесть эту обалденную книгу я перескажу вам самый запомнившийся мне фрагмент.
Джон (главный герой, штурман) принят японцами как уважаемый гость. Он не говорит по-японски, но знает важную для любого иностранца фразу на местном языке - "не понимаю" (вакаримасен). Даймё (самый главный японец в регионе) поручил его развлекать, то есть, чтобы Джон не грустил. Важно понимать, что Джон попал в общество самураев, а значит слово Даймё они должны выполнить даже если это будет стоить им жизни.
И вот они решают развлечь его сексуально. Сторожить Джона поручили троим самураям: двум молодым и одному старику, а также прелестной японке по имени Марико, которая единственная знала язык чужеземца. В итоге, они вчетвером договорились привести одну из наложниц, а Марико сообщила об этом намерении Джону. Джон как честный верующий отреагировал крайне громким возмущением, отчего трое стоящих рядом самураев испугались. Суть в том, что если Джон окажется разочарован их гостеприимством, то Даймё может на полном серьезе их наказать вплоть до отрубленной головы. Те, кто знаком с японской культурой, знают, что лишиться головы для самурая не является чем-то страшным, однако это так если ты лишился головы во время боя, например, или отстаивая свою честь как-то иначе, но если ты лишился головы потому что "приезжий с кошачьими глазами" оказался недоволен предложенным сексуальным досугом, то это позор для тебя.
Джон кричит, что у него жена осталась дома в Европе, и что он не намерен изменять ей. Марико же говорит, что в японской культуре вполне нормально делить ложе с наложницами, а Джон слышит лишь одно: что ему предлагают разврат и измену.
Самураи не понимая речи иностранца решают, что может на предложение заменить девушку парнем гость отреагирует охотнее, потому что у каждого свои вкусы. Марико без зазрения совести предлагает Джону мальчика вместо девушки. На это Джон просто взрывается от возмущения, кричит, что это грех, и что за такое его дома ждёт смерть. Марико же удивленно сообщает, что в Японии это не грех, да и "ваши европейские священники любят это дело тут". У Джона глаза на лоб полезли, а самураи видя все это стали прощаться со своими головами.
Марико в ступоре, а всерьез озадаченные молодые самураи решили спросить совета у старого мудрого самурая. Он, почесав бороду, выдал "ну можно попробовать привести ему собаку". Тут уже продвинутые японцы удивленно стали смотреть в ответ на это. Старик продолжал:
Ну в Корее, я слышал, кто-то так делает. А еще некоторые любят это делать с уткой.Чем чёрт не шутит, решили попробовать. Привели собаку и утку. Джон, на счастье, не понял зачем тут животные, сказал, что ему достаточно будет просто побыть одному, а самураи решили воспользоваться этим и покинули помещение. Только старик подложил утку поближе к гостю на всякий случай перед уходом.
P.S. Есть сериал по книге, он так же называется "Сёгун". Вы ничего не потеряете если не посмотрите его, он уныл.
P.P.S. Если при чтении книги включить саундтрек к "Пиратам карибского моря", то получается невероятно атмосферно.
Взято отсюда https://t.me/soberly_channel/20
Я пишу игру на игровую консоль Playdate на чистом C. Игра в жанре "выживальщик" наподобие Vampire Survivors. Так как в чистом C отсутствуют многие современные объектно-ориентированные удобства мне приходится по-всякому изворачиваться чтобы адаптировать свои идеи в код. В этих заметках ты узнаешь из первых уст как создаётся игруля с нуля от идеи до публикации.
В прошлой главе я описал сеттинг, показал видео с тем что получилось после первой итерации (оно продублировано ниже), а также детально рассказал как я реализовал в коде свой динамический массив с нуля, потому что ничего подобного ни сишка, ни Playdate SDK мне не предоставляют из коробки. Если ты не читал прошлую главу, то лучше начать с неё.
Знаешь, в детективах есть такая особенность: убийца обязательно показывается в первые минуты. Не бывает такого, что убийца впервые появился под конец фильма или серии если это сериал, потому что так будет неинтересно зрителю. И я бы ни за что не поверил, если бы мне кто-то сказал, что в моём уже опубликованном коде есть баг, который я проглядел, и что этот баг в будущем обязательно выйдет мне боком.
Как ты помнишь логику игры я задумал вынести в класс Game. Да, это именно класс, ну по крайней мере в моей системе координат. А компилятор сишки, конечно же, вообще не в курсе что такое класс, но нас это не огорчает. У класса Game есть функции
GameCreate
GameSetup
GameUpdate
GameDestroy
Интерфейс класса Game выглядит вот так:
Обрати внимание на слово typedef на строке 12. Это слово придётся встречать в моём коде часто. Зачем оно тут нужно? Чтобы я мог использовать тип Game как отдельно-живущий полностью самостоятельный тип без постоянного указания слова struct перед ним. То есть, в сишке по-умолчанию если написать так:
struct MyStruct { ... };
а потом попробовать инстанс структуры MyStruct объявить вот так
MyStruct myStruct;
или попробовать MyStruct передать в функцию
void myFunction(const MyStruct *myStruct)
то мы получим ошибку компиляции. А всё потому что надо добавить слово struct перед названием типа:
struct MyStruct myStruct;
и
void myFunction(const struct MyStruct *myStruct)
Ты спросишь "нахуа?". А я отвечу "ну потому что вот так, потому что это сишка, детка". Если в сишке ты объявляешь структуру, то каждый инстанс с типом этой структуры должен иметь приписку что это структура чтобы не дай Боб компилятор не заподозрил что ты имеешь в виду что-то другое.
Полагаю, если ты шарпер или свифтер ты сидишь в афиге от этого, потому что и в C#, и во Свифте просто объявленая структура это самостоятельный тип без всяких приписок. Да, всё так, но C# и Свифт это современные языки, а сишка создавалась примерно за миллион лет до красной революции, а тогда тренды и привычки в разработке были совсем другие, и, в частности, типы данных struct и union были чем-то диковинным, потому при инстанциировании их нужно писать дополнительно слово struct и union соответственно. Время шло, struct и union из равноправных сущностей изменились в статусе: struct стал мегапопулярным (из него появились объекты в ООП), а union остался местечковой заморочкой. Примерно как USB-флэшки и компакт-диски: когда-то и те, и другие были плюс-минус равноправными способами передачи информации, но со временем мы пришли в точку повествования, где современное поколение не в курсе что такое компакт-диск. Да и я сам забыл когда использовал компакт-диски. А нет, вспомнил: когда у стоматолога делал снимок зубов. Я понятия не имею почему стоматологи снимки зубов принципиально скидывают на компакт-диски, а не на USB-флэшки. Но, возможно, чтобы это понять нужно получить медицинское образование вместе со способностью писать от руки непонятным почерком.
Так, со странностью описания структур в сишке понятно, но причём тут слово typedef? А это совершенно другая фича, которая в некотором виде существует во всех современных языках программирования: конструкция typedef existing new; это объявление алиаса примерно как
using new = existing; // в С++
typealias new = existing // в Swift
(как это делать в C# я забыл. Напиши в коментах если знаешь).
Вот только, как ты видишь, в конструкции typedef новое и старое значения переставлены местами. Почему? Потому что сишка, не задавай вопросы!
В итоге, конструкция типа
typedef struct {...} MyStruct;
это указание того, что я буду считать выражение MyStruct алиасом на struct {...} указанную в этой же строке. Иногда можно встретить вот такое:
typedef struct MyStruct {...} MyStruct;
Это то же самое, но с избыточным указанием имени в оригинальной структуре. Первый вариант не имеет имени, и по сути объявляет анонимную структуру (да, в сишке есть анонимные структуры!) и заводит на неё синоним. А второй вариант создаёт именованную структуру, которую можно из коробки использовать с припиской struct, но так как мы тут же объявляем на неё алиас с тем же именем, то struct можно опустить.
Думаешь "ну и дичь!"? А что если я тебе скажу, что в современном C++ эта особенность сохранилась, но только в виде необязательной фичи? Смотри: можно вот прям сегодня открыть твою любимую плюсовую IDE и написать не
std::vector<int> vec;
а
class std::vector<int> vec;
и оно скомпилится! Я когда впервые об этом узнал несколько лет назад моя реакция была примерно такой
Что ж, с typedef'ом разобрались, фух! Давай приступим к сути: к функциям. Пойдём сначала прямо внутрь GameCreate.
Задача GameCreate создать инстанс игры - это билдер-функция, аналог конструктора в C++. Инстанс мы создаём в куче - это позволяет не создавать инстанс на старте приложения, а отложить его создание до получения init-события от операционной системы Playdate. Там, конечно, разница во времени почти нулевая между стартом игры и получением init-события, но чисто технически эта разница существует, потому делаем так.
Единственный аргумент, который нужен для создания инстанса игры это указатель на PlaydateAPI. Без него мы не можем вызывать API у операционной системы Playdate. Указатель на PlaydateAPI это как контекст в Android'е - без него можно писать код конечно, но только сферический код в вакууме, а не реальный код, который нагло и невозбранно взаимодействует с API системы направо и налево.
Так как я имитирую ООП, то некоторые ООП-шные вещи мне следует реализовывать на ручной тяге. В частности, инициализация всех полей структуры. То есть, я дал себе слово, что create-функции у меня должны обязательно инициализовать все поля создаваемой структуры как это сделано в Свифте из коробки, например. Почему это так важно? Потому что если ты создаёшь инстанс чего либо в куче, то гарантию того, что в поезде выделенных байтов не будет мусора, никто тебе не даст. Выделять память в куче это примерно как сесть покушать за стол на фуд-корте в торговом центре. Стол может оказаться кристально чистым, а может быть невероятно грязным словно на нём только что ела семья потомственных свиней.
На строке 16 происходит как раз выделение памяти для инстанса Game, который функция вернёт в конце. Далее мы заполняем все поля ничего не пропуская.
Указатель на PlaydateAPI мы прихранили в игре на строке 17 - это важно. Далее, мы создаём инстанс машины - той самой, на которой катается игрок и расстреливает животных (смотри видео). Кстати, про машину - это отдельный класс, инстанс которого хранится в игре в единственном экземпляре, так как машина только одна. Создаётся инстанс машины тоже в куче потому что в начале я думал, что инстансы всего буду создавать в куче. Спойлер: уже буквально после машины я передумал, так как достаточно хранить всё что нужно в виде значений как есть внутри игры как часть игры. Но с машиной пока так.
Машина имеет совсем мало полей: ей много и не надо. Это:
позиция в формате Vec2f (вектор имеющий поля x и y в формате float) - строка 9
направление angle, один из восьми вариантов направлений машины. Это перечисление (enum), объявление которого я покажу очень скоро - строка 10
булевое значение isMoving, которое равно 0 если машина стоит, и 1 если машина движется. Так как типа bool в сишке нет мы нагло используем int - строка 11
значение ускорения, которое нужно для реализации физики движения в формате float - строка 12
И ещё две функции: конструктор и деструктор (строки 15 и 16 соответственно). Как и API массива эти функции принимают указатель на функцию realloc так как эту функцию нам предоставляет PlaydateAPI.
Теперь покажу как выглядит PlayerVehicleAngle:
Как ты помнишь из первой главы, в значения перечислений я вставляю имена этих перечислений в качестве префикса чтобы не было пересечений имён так как в сишке все значения перечислений доступны в глобальном пространстве имён. На Свифте я бы всё сделал в разы проще:
Как бы выглядел PlayerVehicleAngle если бы проект писался на Свифте где-то в альтернативной вселенной
Ну или на C#:
Как бы выглядело описание PlayerVehicleAngle если бы проект писался на C# в другой альтернативной вселенной
И как же без С++:
Как бы выглядело описание PlayerVehicleAngle если бы проект писался на C++ (где-то упала одна моя скупая мужская слеза) в ещё одной альтернативной вселенной
С направлением всё понятно, полагаю, как и с тем, как я описываю перечисления. Теперь вернёмся к машине, то есть, ко классу PlayerVehicle. Его объявление я тебе показал, давай теперь покажу реализацию.
Как видишь, всё очень просто - создаём инстанс также в куче, присваиваем все без исключения поля и возвращаем что получилось. Это я говорю про конструктор - функцию PlayerVehicleCreate. А деструктор, PlayerVehicleDestroy, просто чистит выделенную под машину память.
Возвращаемся в игру туда, откуда мы ушли: в GameCreate, конструктор игры. На строке 19 я присваиваю cameraOffset - это смещение камеры, которое нужно для отрисовки позиции всего. Об этом подробнее позже. Далее я присваиваю NULL в поля cactusImage и sandImage. Это вот как раз то, о чём я говорил: это важно сделать, потому что иначе эти значения будут иметь мусор, а так как их тип это указатель (LCDBitmap *cactusImage), то без инициализации нулём на старте эти указатели будут указывать чёрт знает куда, и если я вдруг решу обратиться по этому адресу, то моя программа пойдёт по другому известному адресу, то есть, упадёт, крашнется или словит segfault (или 'сегфолт' по-русски если верить словарю Даля). Этого мы не хотим, потому что не для этого американцы создавали эту маленькую жёлтую консоль, а для бесконечного фана.
Далее прошу обратить внимание на три строки: 22, 23 и 24. В этих строках мы инициализируем наши динамические массивы. Первый это кактусы (cactuses), второй - песчаные горочки (просто sands), третий - перекати-поля (tumbleweeds). Если у тебя после прочтения первой главы остался вопрос как инициализировать массив, так скрупулёзно созданный мной байтик за байтиком, то вот это как раз тот самый пример.
Далее строку 25 давай пока пропустим - потом всё объясню. А вот после у нас идёт заполнение массивов кактусов и песков (буду говорить так вместо "песчаных горочек" , российский усатый политик тут ни при чём). Самые главные строчки это 40 и 43. В них мы непосредственно добавляем свежесозданный кактус либо песок в соответствующий массив вызывая уже известную из первой главы функцию ArrayPushBack.
Как именно заполняется карта игры песком и кактусами и что за такие RangeCreate (строки 31 и 33) и RandomIntInRange (строка 37)? Range это невероятно удобный вспомогательный класс, который я создал подглядев идею в стандартной библиотеке Свифта. Range(x, y) это диапазон значений по аналогии как в математике указывается [x, y), что означает диапазон от числа x включая x (об этом свидетельствует квадратная скобка) и до числа y НЕ включая y (об этом свидетельствует круглая скобка).
А ещё я решил прикрутить генератор случайных чисел прям к диапазону: если у тебя есть диапазон, скажем, от нуля включительно до пятидесяти невключительно, то можно вызвать функцию RandomIntInRange и передать Range, а в ответ вернётся случайное число в указанном диапазоне. Мне показалось это в разы удобнее всех тех миллионов функций для генерации случайных чисел, которыми забит сайт StackOverflow.
Теперь давай я расскажу как же я заполняю игровое поле кактусами, песком, и почему в этом коде нет перекати-поле. Самый тупой вариант это нагеренить N объектов (кактусов и песков) со случайными координатами x и y в пределах размера поля (размер поля, кстати, от -1000 до 1000, то есть, 2000). Но так делать не надо потому что с таким подходом чисто теоретически может получиться, что на достаточно большом куске поля (например, размером с экран, то есть 400 на 240) не выпадет ни одного объекта. И в таком случае поле будет выглядеть пустым и будет казаться, что машина едет по белому листу в MS Paint'е. Такое нам не нужно. Вместо этого нужно чтобы игрок регулярно видел реквизит, напоминающий о том, что мы едем именно по пустыне. Можно, конечно, повтыкать через одно и то же расстояние последовательно кактусы и пески как будто их вкопали солдаты квадратно-гнездовым методом, но это будет выглядеть обсосно и без души. Важно найти золотую середину - использовать рандом, но укротить его. Потому алгоритм я выбрал такой: я делю карту на прямоугольные блоки 150 на 100 (строка 27)
const Vec2i blockSize = Vec2iCreate(75 * 2, 50 * 2);
и в каждый блок в случайную точку этого блока ставлю один объект: либо кактус, либо песок. Поставить ли кактус или песок я определяю так же рандомом на строке 37:
const int isCactus = RandomIntInRange(RangeCreate(0, 2));
И если isCactus равна 1, значит ставим кактус, иначе - песок.
Вообще, конечно, можно попробовать ради простого теста сгенерить столько же кактусов и песков с абсолютно рандомной позицией без всяких ограничений в виде блоков. Давай вот прям щас и попробуем это сделать. Смотри: всего кактусов/песков на карте создаётся
ceil(2000 / 150) * (2000 / 100)
ceil это функция округления вверх. В математике она обозначается как квадратные скобки без нижних пип (у квадратных скобок есть верхние и нижние горизонтальные пипы, вот если нижние убрать, то получится округление вверх). Почему я добавил эту функцию сюда - потому что деление 2000 на 150 не даст целое число, а если нам нужно точное значение, то надо решить что делаем с остатком. Исходя из условия нашего цикла на строке 28
for (float xMin = -1000; xMin <= 1000; xMin += blockSize.x + 20) {
остаток следует учитывать. Упс, я только что заметил, что есть ещё +20 на той же строке 28 - это я делаю пробел между блоками для пущей правдоподобности чтобы не было двух кактусов приклеенных друг к другу. Хм, тогда пересчитываем всё. По оси x я делаю отступ 20, по оси y - 15. Значит, всего кактусов/песков на карте создаётся
ceil(2000 / (150 + 20)) * ceil(2000 / (100 + 15)
это упрощается в
12 * 18 = 216
Значит, всего 216 объектов. Теперь давай перепишем генерацию кактусов/песков на рандомную позицию от -1000 до 1000 и посмотрим что получится.
В целом такой вариант тоже выглядит неплохо, однако бывают большие участки вообще без ничего. А один из тысячи вариантов генерации точно сотворит такое, что будет огромная дыра, а такого мне не надо. Так что поигрались и хватит - возвращаем старый алгоритм и идём дальше.
Кстати, возможно логичнее было бы положить создание кактусов в GameSetup, а не GameCreate. Ну да ладно - как сделали так сделали.
На этом с функцией GameCreate мы закончили. Далее идёт GameSetup - небольшая функция, которая стартует игру. Как я говорил, она по логике похожа на GameCreate потому что тоже вызывается единожды и тоже до всех обновлений (тиков) игры, но строго после GameCreate.
Тут кода мало: мы сначала вызываем функцию с забавным названием srand, которая инициализирует генератор случайных чисел. Можно её и не вызывать, но тогда на пятидесятую игру ты начнёшь замечать, что все случайности в игре (например, позиции кактусов) ничуть не случайны. Нам это не нужно. Далее мы вызываем GamePreloadImages для загрузки картинок (помнишь, мы все картинки проинициализировали как NULL в GameCreate?). После этого мы устанавливаем значение в cameraOffset равным половине экрана - так надо. Давай я подробнее расскажу про загрузку картинок - там есть интересные вещи.
Все картинки у меня лежат в папке images в проекте. Кадры машины, которых 8 штук по одному для каждого направления, имеют гениальные названия: 1.png, 2.png и так далее до восьми. Для загрузки одной картинки мне нужно вызвать функцию loadImageAtPath, которая принимает путь к картинке в пределах проекта (images/1.png, images/2.png и так далее) и необходимый указатель на PlaydateAPI.
(LCDBitmap это тип из Playdate SDK, который означает картинку) Я честно позаимствовал эту функцию в примере игры на сишке у самого Playdate, так что тут ничего особенного нет. А вот то, как я загружаю, это следует понять. Как вообще загрузить 8 картинок с последовательными названиями? Конечно же, можно сделать просто 8 строк типа такого:
Такой подход в простонародье называют китайский код. Конечно же, я делаю не так, а реализовал цикл. Имя картинки в цикле можно сгенерить зная индекс итерации. И в языках высокого уровня использовалась бы функция форматирования строки. Вот как я бы такое сделал на Свифте:
В коде на Свифте важно обратить внимание на генерацию полного пути к файлу картинки. Конструкция покрашенная красным создаёт строку вставляя в неё значение переменной-итератора i, которое меняется от 1 до 8 включительно. В сишке, к большому-пребольшому сожалению, нет простого API для подобной генерации динамических строк. Да, есть функция sprintf, которая позволяет вытворять что-то похожее, однако она не заботится о размере строки потому что это наша забота, а такое нам не нужно. Потому этой функцией я тоже не пользуюсь. Вместо этого я в своём сишном коде создал один раз шаблон пути к файлу "images/0.png" (строка 205) и в каждой итерации просто подменяю одну буковку, точнее, циферку перед точкой (строка 207):
imagePath[7] = '0' + i;
Что же тут такого особенного? А то, что в варианте на Свифте (или на любом другом языке высокого уровня в том числе С++ с той же либой fmt) каждую итерацию будет выделяться и чиститься по одной динамической строке, а у нас в сишке выделяется 0 динамических строк, есть только одна статическая (потому что нам заранее известен её размер), а мы аккуратно скальпелем меняем в ней один байтик и вуаля - никаких строк не нужно генерить. Не нужно использовать хитрый форматер, я сам себе форматер!
Зачем я так делаю? Прекрасный вопрос, спасибо. Я это делаю точно не для того чтобы уменьшить нагрузку на процессор консоли потому что это получится экономия на спичках. Я так делаю лишь потому что это сделать очень просто в сишке и ещё потому что в сишке нет нормальной либы для форматирования динамических строк.
Кстати, подробнее про строку в коде imagePath[7] = '0' + i;. Тут происходит магия ascii-символов. Ноль в одинарных кавычках это литерал одиночного символа равный нулю, как строка, но только один её элемент. Если строка это поезд, то ноль в одинарных кавычках это один вагон. Если к нему прибавить целое число, то символ станет другим. Если прибавить один, то символ изменится на один вперёд в таблице ascii. А в таблице ascii цифры стоят последовательно, к счастью. То есть, если к нулю я прибавлю 1, то получу символ единицы. Если бы в таблице ascii цифры были бы в беспорядке, то данный фокус бы не сработал, и задачу пришлось бы решать либо форматтером, либо китайским кодом.
Фух. Как самочувствие? Не устал ещё? Мы прошли хороший путь: разобрали GameCreate, GameSetup, а сейчас я хочу завершить эту главу функцией, которой завершается игра - GameDestroy.
Тут всё суперпросто: мы уничтожаем объект машины (строка 186) так как он создавался в куче как и массивы, потом уничтожаем все три массива, которые аналогично последовательно создавали внутри GameCreate (кактусы, пески и перекати-поле), а потом уничтожаем саму игру.
Кстати, в самом начале функции я проверяю game на NULL (строка 182), и если это так, то просто выхожу из функции. Я сначала думал, что это логично и позволяет писать безопасный код. В частности, на С++ я так делаю всегда. Однако на сишке в отличие от С++ почти всё передаётся через указатели, и ты просто запаришься проверять всё на NULL (например, представь если везде проверять указатель PlaydateAPI на NULL). Потому это был первый и последний раз когда я осуществил такую проверку - далее я просто решил для себя, что везде где ожидаются ненулевые данные я просто буду верить себе на слово, что там не NULL.
Итого в первый день я создал машину и поле, а во второй - кактусы и песчаные насыпи.
А что же с функцией GameUpdate? GameUpdate это самая важная функция в моём коде на данный момент потому что она в отличие от всех прочих функций класса Game вызывается стабильно каждый тик примерно так же, как сердце гоняет кровь туда и обратно по организму каждую секунду. Без этой функции игры не будет. Но разберём эту функцию мы уже в следующей главе.
Я пишу игру на игровую консоль 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 не будет работать из коробки.
А что у нас далее? Далее идёт отрисовка перекати-поле. И тут не всё так банально как у кактусов и куч песка. У перекати-поля есть тень, а сам объект подпрыгивает по синусоиде как описывалось в прошлой главе. Давай я ещё раз покажу видосик как оно всё выглядит.
А вот как выглядит код отрисовки перекати-поля:
Тут у нас два цикла вместо одного. Это потому что сначала мы рисуем тень, а потом уже саму тушку перекати-поля. И заметь, что сначала рисуются все тени, а потом уже все тушки. Можно сделать всё одним циклом и рисовать сначала тень, потом тушки каждого объекта по очереди, и я так и сделал в начале, но в таком случае получается так, что при пересечении друг с другом разные перекати-поле могут иметь тень поверх тушки. То есть, тень второго в цикле перекати-поля рисуется после тушки первого, и если их позиции окажутся рядом на карте, то визуально получится, что тень второго как бы "выше" тушки первого, что в реальной жизни невозможно потому что в жизни тень всегда рисуется на поверхности, на которую эта тень падает (в нашем случае это плоскость земли (нет, я не фанат плоской земли)). В общем, если делать один цикл, то выглядеть всё будет обсосно. Насколько обсосно? Давай покажу гифкой:
Вот поэтому сначала рисуются все тени перекати-поля, а потом все тушки. Более того, если в будущем у других объектов тоже будут тени, то они тоже должны рисоваться до отрисовки тушек всех объектов. Такая вот логика, и мы её заложники независимо от платформы.
Первый цикл банальный и привычный:
получаем константный указатель на объект 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 в коде).
И внезапно мы получаем очень странную картину: кадр тушки перекати-поля иногда превращается в свою же тень отображая неправдоподобную ситуацию - двойную тень. Представь в жизни такое: идёт человек, отбрасывает тень, и иногда вместо самого человека в воздухе висит ещё одна его тень. Почему так получилось? Почему неопределённое поведение не уронило игру вместо этого? Причина в том, что сразу после массива в памяти лежит как раз картинка тени. А порядок полей в структуре в сишке гарантируется. Помнишь, во второй главе я показывал структуру 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 давай мы его немного изменим - в два раза и в десять раз - и глянем что у нас получилось.
Вот потому там 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
Достаточно легко на самом деле, и это, по идее, курс математики за пятый класс - линейные функции на координатной плоскости. Тот редкий случай когда школьная программа пригодилась мне во взрослой жизни!
Через 5 лет я сижу на тренинге по продажам. Тренер рассказывает, что для того чтобы мягко подчинить себе человека надо сначала попросить его сделать что-то незначительное, а после уже можно просить о большем. И уточняет:
Этот способ используется ещё шпаной на районе. Одна фраза, которая склоняет волю без явной агрессии. Что это за фраза, кто знает?
Все молчат. Один я понимаю руку и выдаю:
Иди сюда!
Всё верно. Если ты согласился подойти после этого, значит согласишься и бабло отдать, и мобилу.
Возвращаемся в школьные годы.
К 11-ому классу доёбы Паши до меня превратились в ежедневный ритуал когда я шёл из школы. Денег ему отсыпáть я не хотел, да и часто вроде не отсыпáл, но стресс всё равно получал. И меня со временем стали посещать шальные мысли о том, что король голый, царь ненастоящий и Паша-то в реальности никому пизды на моих глазах не давал, и мне в том числе. Угрожать это он всегда рад, а чтобы прям отпиздить - этого не было. Не то чтобы я соскучился по пиздюлям. Скорее я думал, что можно заставить Пашу отъебаться от меня навсегда используя лишь мой мозг, показать Паше что я вне системы. Мозга на самом деле надо немного для моей задумки, а смелости надо будет отсыпать потому что никто не отменял тот факт, что у Паши пиздец какой сильный удар. Размышлял я много перед тем, как попробовать это сделать. В частности, меня сильно бесило, что мне не нравится выполнять ебанутые команды старшаков, хотя я их выполняю словно во мне включается тот самый рабский ген о котором трубили русофобы год назад на весь твитор. Главную работу надо было мне проделать именно над собой: сломать определённые установки в своей голове, обмазаться смелостью и надеяться, что всё сработает как надо.
В очередной день я иду по двору со школы и слышу пашино:
Женя, иди сюда!
Я покорно пошёл. И у меня в голове вдруг щёлкнуло: вот в чём проблема - надо не соглашаться на «иди сюда». Да, страшно будет это сделать. Но если я соглашаюсь на первую простую просьбу, то включается привычка, по которой я выполняю остальные указания Паши и испытываю ёбаный стресс, ну или включается рабский ген бережно воспитанный миллиардами лет пребывания моими предками в роли крепостных крестьян в Российской империи: кому как угодно. Русофобам чмоки.
Но вот незадача: я уже иду к Паше. Я слишком поздно понял, что надо не идти, а уже не идти как-то некомильфо что ли. Убежать резко может? Странно будет как минимум. Неадекватно согласиться, а через секунду дать по тапочкам. Немного странно, что я думал о соблюдении адекватности в тот момент, но имеем что имеем. В следующий раз надо будет обязательно не идти когда Паша меня позовёт. А что сейчас делать? Пиздец как обидно, что я проебал нынешний раз. Из-за этого я решил импровизировать.
Женя, салам. Деньги есть?
Нет.
А если найду - мои?
Нет.
Паша стоит в ахуе потому что ранее никто не нарушал протокол и всегда говорил «да»:
Чего, блять? Деньги есть, я спрашиваю?
Нет, - всё так же с покерфэйсом отвечаю я.
А если найду тогда мои? - Паша повышает голос.
Нет, - всё так же отвечаю я.
Почему, блять? - орёт на меня Паша.
Потому что ты не найдёшь денег - их же у меня нет.
Паша начал разминать шею. Видимо, показывает как сильно он хочет меня отпиздить. Правда, так и не пиздит. Значит, просто выёбывается.
Так, смотри, блять, - начал дерзко объяснять Паша, - если ты говоришь, что бабла нет, то ты должен говорить, что бабло моё если я его найду. Понял?
Не очень, - ответил я.
С хуя ли? - ор на весь двор.
Важное уточнение: изначально я шёл слушая музыку в наушниках. У меня были какие-то пафосные наушники, не помню где я их взял, и мобила Nokia N95 Music Edition. Пиздатая мобила и пиздатые наушники, если не считать, что мобила оказалась китайской поделкой и стала разваливаться через месяц, но сейчас это не важно. На данный момент я понял, что разговор затянется надолго и стал сворачивать наушники и убирать их в карман пиджака (я в пиджаке ходил в школу, хуле ты думал; кстати, в пиджаке ходить пиздато что бы там не говорили). Паша обратил внимание на наушники и на то куда я их положил.
Смотри ещё раз, - продолжил он пояснять мне как имбецилу, - я тебе говорю ‘если найду мои?’, ты что должен отвечать?
Нет, - я решил играть дебила до последнего. Иначе зачем вообще это всё?
Почему? - опять заорал Паша тряся кулаками.
Ну смотри, - я начал объяснять словно наивный дурачок, - вот если у меня деньги есть я просто говорю ‘да’. А если денег нет, то какой смысл говорить что если ты их найдёшь, то они твои, если ты их никогда не найдёшь?
Пиздец, блять! - Паша закинул голову к небу, - что за уши у тебя?
Не помню название.
Дай покатать.
Блять, наушники я ни за что не отдам, я скорее порву их, чем отдам кому-либо.
Нет, - спокойно сказал я, хотя спокойствие стоит дорого.
Ясно, - ответил Паша, - пойдём-ка.
И повёл меня в соседний двор. Зачем - хуй знает. Но точно я знал, что если бы Паша хотел меня отпиздить, то место нашего диалога было идеальным для этого, а вести меня в соседний двор, который мало чем отличается от нашего двора, идея странная. Но мне в принципе похуй уже - я решил идти до конца как бухой игрок в казино, который решил пойти ва-банк.
В соседнем дворе играл с мячом один щегол, который был на пару лет меня моложе. По идее, я бы его мог лутать вообще легко если бы пытался двигаться и быть дворовой шпаной, но впизду быть таким.
Ты, иди сюда! - крикнул Паша щеглу.
Тот подошёл всем своим видом показывая как он боится. Он посаламкался с нами. Паша его по дружески приобнял и спросил:
Чё, как дела?
Да нормально, - ответил парниша с интонацией говорящей «отстань, пожалуйста».
Где мои деньги, сука? - Паша резко перешёл на дерзкую интонацию схватив щегла за шею.
Ну хватит, - заныл щегол, - я достану всё, обещаю.
Ладно, верю, - отпустил его Паша, - вот скажи мне что лучше: получить пизды или отдать уши?
Пизды, конечно, - ответил парниша и в испуге схватился за свои уши, которые у него на голове растут.
Ты долбоёб, я про наушники говорю, - раздражённо поправил его Паша.
А. Ну тогда уши, - ответил парниша.
Паша посмотрел на меня с лицом таким типа «смотри и учись мудрости щегольской». А я стою и делаю вид, что нихуя не понимаю.
Ладно, пиздуй, - Паша толкнул щегла. Тот потёр шею и поплёлся прочь.
Идём, - позвал Паша меня за собой, и мы пошли дальше.
Я всё это наблюдал с покерфэйсом. У меня было простое правило в тот момент: если меня не пиздят, то это просто кино, которое великодушно мне показывает Паша. Шли мы достаточно долго. В итоге припиздовали на другой край Тастака (минут 10 ходьбы). Там тусит какая-то другая шпана. Паша подошёл к ним - мы стали с ними саламкаться. И тут внезапно одного из них я узнаю. Это самая настоящая неожиданная встреча. Я даже не думал его увидеть не то что в Тастаке, а вообще когда-либо в городе. Но это был он - это был Вайнах, здоровый кавказец. Тот самый, который пытался у меня отжать прежнюю мобилу в Real Gangsta Mall.
Оооо, салам, - протянул он мне.
Салам, - спокойно ответил я.
Это ты же двигался в Real Gansta Mall в прошлом году с девушкой!
Звучит как вопрос, но это было утверждение. А ещё нихуясебе: я, оказывается, двигался. Так вот что нужно чтобы двигаться - просто куда-то припиздовать и светануть фэйсом? Лол.
Да, это был я, - ответил я.
Если Паша хотел произвести на меня впечатление, то у него получилось. Я стою в ёбаном ахуе.
Это как в игре Batman на Sega где боссы собирались вместе в предпоследнем уровне. Вопрос: что будет дальше? Эльдар вылезет из под земли и Жаник приземлится на парашюте?
Но дальше мы просто попиздовали в сторону дома. Напомню, что я иду неотпизженный, денег неотдавший и ещё и нарушающий протокол общения со старшаками. Пока я в плюсе. Неужели мой план сработает? Неужели сегодня будет первый день в истории когда чушпаны победят?
Пока мы шли я решил начать диалог первым:
Паша, у тебя сердце есть?
В смысле?
Ну ты кого-нибудь любишь?
Люблю того, кто это заслуживает. Маму, там, ещё кого надо. Тебе какое дело?
Я промолчал. Далее надо было сказать «а хули ты доёбываешься до меня тогда? Я ведь тебе ничего не сделал», но это превышало допустимый градус дерзости, который я себе на сегодня выписал.
Короче, Женя, раз ты мне мозг ебёшь сегодня ты мне отдашь погонять свои уши.
Нет, не отдам, извянки, - сказал я.
Паша смотрит на меня дерзко, но уже не настолько дерзко, как было 10 минут назад. Видимо, он как и я чуть заебался ходить по Тастаку и выгуливать меня как тёлку на свидании. Мы дошли до точки где мне в одну сторону, а ему в другую (блять, как будто реально на свиданке).
Женя, я хочу тебе сказать, что ты тупой и не понимаешь, что когда тебе говорят «найду мои?» ты должен отвечать «да» потому что тебе что с того если денег нет. Ясно?
Нет, - спокойно ответил я.
Соображай своей головой, - проорал Паша и направил палец на мою голову.
Потом он чуть подождал и спросил:
Уши дай погонять.
Нет, - это у меня сегодня популярное слово.
Взгляд Пашы упал на тот самый боковой карман пиджака куда я положил наушники: оказалось, что я не до конца их засунул в тот самый карман, и проводок всё это время чуть торчал соблазняя схватить за него и вытащить наушники. Рука Паши резко выпала к этому проводку, но я успел увернуться. Мне просто повезло. Непонятно откуда у меня реакция быстрее чем у Паши, но в тот раз было именно так.
Эхехехе, - поржал странно Паша как Волдеморт в тот самом меме и ушёл.
Я, наконец, побрёл домой обдумывая всё что произошло, и как это скажется на моих отношениях с Пашей в будущем. Победа ли это или Паша как единственный шпана с мозгами придумает контрход?
На следующий день я шёл из школы и заходя в подъезд услышал привычное:
Женя, иди сюда!
Паша стоял с кентами у соседнего подъезда. А мне так в падлу было идти к ним, да и подъезд мой в метре от меня. Может попробовать? Я проигнорировал этот крик и зашёл в подъезд.
Женя пидараааас! - проорал Паша.
Я боялся, что они щас прибегут ко мне в подъезд меня пиздить, но ничего не случилось ни в тот день, ни в другие дни. И поднимаясь домой помимо ожидания, что сейчас они прибегут меня пиздить я вдруг ощутил что-то новое - ощущение, что я больше не подконтролен Паше. После этого Паша Хегай от меня отъебался навсегда словно я стал вне системы.
На этом можно было бы завершить рассказы про Тастак, но финальный босс ещё впереди, не отключайся.
Всё описанное мной здесь приключилось со мной в нулевые. Сейчас Тастак стал другим:
мой двор переделали полностью, убрали все странные советские покосившееся столбы и турники и заменили скамейки на нормальные, в том числе ту, возле которой зарезали парнишу из соседнего дома;
соседний двор где щегол боялся за свои уши вообще сделали по красоте: поставили крытое футбольно-баскетбольное поле и положили везде ровный тротуар. Лайк акимату;
улицу Болотникова отремонтировали до неузнаваемости, эти ваши Европы позавидуют. Те два старых киоска снесли к хуям и вместо них поставили большое красивое коммерческое здание с хипстерской кофейней, столовкой и самсушной. Стало очень цивильно;
Квася, я слышал, то ли в тюрьму попал, то ли в больницу - точно не помню, да и в целом уже похуй. Ноль сентиментов, хотя и ноль злорадства;
от Квакера, я слышал, ушла его девушка к его лучшему другу. Тоже ноль сентиментов и ноль злорадства;
настоящие имена Кваси и Квакера я так и не узнал, так что для меня они остаются Квасей и Квакером;
что стало с Эльдаром, Вайнахом, Пашей, Жаником, Арманом и Олжасом - я хз. Надеюсь, что они выросли в адекватных граждан потому что нам с ними и нашим детям с их детьми ещё жить в одном обществе;
ах да, Лёха, кент Паши, сел в тюрьму за попытку обнести чью-то хату - это я тебе рассказывал.
Замечу, что всё что со мной происходило было достаточно лайтово по сравнению с событиями из «Слова пацана». Не скрываю, что в Алматы порядок менты навели быстрее установив монополию на насилие, а то, что досталось мне это ошмётки, которые не успели прибрать.
Реальный беспредел с ОПГ и огнестрелом происходил не в Алматы, а знаешь где? В Караганде. Я не шучу. В Караганде и Карагандинской области (Темиртау, Степногорск). Если есть знакомый карагандинец, то можно его поспрашивать про девяностые в Караганде. Правда, карагандинцы не любят трепаться об этом периоде, но если набухать его, то, думаю, сработает.
Ещё немного районной мудрости от программиста из Тастака.
Если ты видишь человека, который во взрослом возрасте ведёт себя как шпана на районе как минимум в общении пытаясь показать какой он чёткий по дворовым понятиям, то этот чел точно никогда не был авторитетом. Скорее наоборот: он был последним лохом, и из-за этого у него сохранилась куча комплексов, которые он так и не перевалил и не высрал. Каждый раз когда такой чел пытается вести себя как чёткий пацан представляй как его чморят старшаки - сразу всё станет проще. А те, кто глотнул жизнь на районе сполна, и кто смог из этого вырасти, те стали адекватными людьми: это большинство нормальных людей вокруг нас.
Спасибо, что прочёл этот длинный текст. Чмоки всем тастакским генгста.
P.S. Чтобы ты ещё больше окунулся в генгста романтику Алматы нулевых вот тебе песня от популярного алма-атинского рэп-коллектива того времени: группа Ghetto Dogs, песня «Генгста»
Если тебе нравится как я пишу, то тебе будет интересно почитать мой блог путешественника в Телеге (можно читать как книгу) https://t.me/binarynomad
Я пишу игру на игровую консоль 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, которая весь описываемый мной тут балаган отрисовывает чтобы игрок видел что происходит, иначе зачем всё это?
Отрисовку мы рассмотрим в следующей главе, а тебе спасибо что дочитал. Если нравится как я пишу и хочешь меня поддержать денюшкой, то я есть на патреоне и бусти.