Как на ассемблере написать игру

ассемблер

Хочешь попрактиковаться в кодинге на ассемблере? Давай вместе шаг за шагом создадим игру и запустим ее прямо из загрузочного сектора твоего компьютера. Если ты думаешь, что 512 байт маловато для полноценной игры, не спеши с выводами. К концу статьи ты сможешь сделать ее своими руками!

Да, затолкать что-то вразумительное в 512 байт загрузочного сектора — та еще задачка. Бутсекторная программа выполняется до запуска операционной системы, а значит, функции ОС ей недоступны.

Даже для такого, казалось бы, простого действия, как вывести число на экран, придется писать свою собственную подпрограмму. Кроме того, забудь о высоком разрешении экрана, простом и удобном доступе к GPU и звуковой карте. А еще учти, что BIOS, выполняя бутсекторную программу, смотрит на процессор твоего Ryzen или Core i9 как на примитивный 16-битный 8088.

РЕКОМЕНДУЕМ:
Как создать игру, которая уместится в один твит

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

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

А теперь возьмемся за реализацию!

Подготовка

Говорим компилятору, что наша программа 16-битная, резервируем несколько ячеек памяти под переменные, прыгаем на точку входа. Обрати внимание, что мы здесь не создаем переменные, не выделяем для них память, а просто задаем мнемонику для ячеек памяти, которые уже существуют.

Делаем еще несколько подготовительных телодвижений:

  • переходим в текстовый режим 25 × 80 и очищаем экран;
  • сбрасываем «флаг направления», чтобы строки обрабатывались слева направо, а не наоборот (когда будем обращаться к инструкциям вроде stosw);
  • сегментные регистры нацеливаем на область оперативной памяти, которая отображена на видеопамять. Так нам будет удобней отрисовывать игровое поле.

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

Надо дать игроку успеть приноровиться к управлению, поэтому первую трубу отрисовываем только после 160-го кадра. В next запишем число 160 ( 0xA0) и пишем не mov ax, 0x00A0, а mov al, 0xA0. Нам, конечно, важно, чтобы в AH был ноль, но мы точно знаем, что он и так уже там, поэтому тратить целый байт на повторное обнуление мы не будем.

(Пере)запускаем игру

Выводим название игры. Здесь каждый символ кодируется двумя байтами: цвет и символ. Цвет задаем только один раз ( 0x0F во втором mov). Так мы экономим еще несколько байтов.

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

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

Каким образом игнорируем? Вызываем первую функцию ( 0x01) прерывания 0x16, то есть смотрим, нет ли чего-то в буфере клавиатуры. Обращаемся к 0x01 в цикле, до тех пор пока не опустошим буфер. А опустошаем мы его с помощью функции 0x00 того же прерывания. Опустошив буфер, вызываем 0x00 еще раз и ждем, пока игрок клацнет по клавиатуре.

Начинаем игровой цикл: высчитываем координаты птицы и рисуем ее

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

Текущее значение гравитации помещаем в AL, чтобы рассчитать позицию на экране. Учти, что bird — это дробное число (пять бит на целую часть, три бита на дробную).

Итоговый результат умножаем на 20. Так мы подстраиваемся под то, что каждая строка на мониторе состоит из 160 байт. Затем добавляем 32 к полученному числу, чтобы птичка не жалась к левой границе, а летела на некотором расстоянии от нее.

То, что получилось, помещаем в DI инструкцией xchg ax, di, поскольку AX нельзя применять в качестве указателя. Обрати внимание: мы здесь используем xchg вместо mov не потому, что хотим сберечь значение, которое хранил DI, а потому, что с mov код получился бы больше на целый байт!

Счетчик рисуемых кадров Сadr нам нужен, чтобы высчитывать моменты, когда надо рисовать «крыло вверх». Перед тем как рисовать крыло (см. инструкции вроде mov word [di]), мы сначала считываем текущее содержимое соответствующей ячейки памяти (см. инструкции вроде mov al, [di]). Смещение 160 позволяет посмотреть, что находится под птицей, а 2 — что сбоку от нее. Ты же помнишь, что каждый символ у нас кодируется двумя байтами (цвет и символ)?

РЕКОМЕНДУЕМ:
Как самому создать игру

Продолжаем игровой цикл: смотрим, нет ли столкновений

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

Если AL не равно 0x40, столкновение есть. Что это за мистика такая, как это число дает нам такую ценную информацию? Суть вот в чем. Если в анализируемых ячейках не было препятствий, значит, там хранятся символы пробела. А код пробела — 0x20; 0x40 — это сумма двух пробелов. Если результат отличен от 0x40, значит, птица во что-то влетела. В таком случае пишем многозначительное BA][.

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

Если игра идет своим чередом, то при помощи подпрограммы DelayBeforeCadr (ее мы напишем чуть позже), делаем небольшую задержку перед следующим сдвигом экрана.

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

Завершаем игровой цикл: проверяем, не встретилась ли на пути труба

Если птичка встретила на своем пути трубу, плюсуем счетчик score и выводим его обновленное значение на экран. Для этого переводим число в строковое представление и рисуем его на экране. На каждую цифру по одной итерации цикла.

Смотрим, не нажал ли игрок клавишу (ты еще помнишь, это первая функция прерывания 0x16). Если игрок ничего не нажимал, переходим к следующей итерации игрового цикла. Но не напрямую туда, а транзитом через @@ToJmp_Main. Можно было бы и сразу прыгнуть на @@MainGameLoop, но тогда выйдет на два байта больше, потому что эта метка далеко от текущей позиции кода.

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

Все, главный игровой цикл готов. Осталось написать две подпрограммы: DelayBeforeCadr, которая делает задержку, и MoveScene, которая сдвигает игровое поле влево на один символ.

DelayBeforeCadr: делаем задержку

Задержку организуем при помощи системных часов, к которым обращаемся через int 0x1A. Системные часы тикают каждые 55 мс — отсчитывают время, прошедшее с момента включения компьютера. Функция 0x00 возвращает текущее количество тиков в CX:DX (четырехбайтовое число).

Как работает DelayBeforeCadr? Считываем текущее количество тиков, сохраняем его. И затем опять считываем его, но уже в цикле. Как только значение меняется — выходим из цикла. Здесь же инкрементируем счетчик cadr. Логичней, конечно, было бы инкрементировать не здесь, а там, где эта подпрограмма используется, но тогда этот инкремент нужно будет делать при каждом обращении к DelayBeforeCadr. А это дополнительные байты, которые у нас в дефиците.

MoveScene: перерисовываем игровое поле

Сначала сдвигаем игровое поле влево на один символ. Сдвигаем все строки, за исключением первой. Там у нас написано название игры и сколько очков набрал игрок. Их двигать не надо.

Нацеливаем SI на первый символ первой строки (отсчитываем с нуля), а DI — на нулевой символ первой строки. Зачем? Чтобы, когда будем делать movsw, два байта из [SI] перемещались в [DI]. Помнишь ведь, что эти два байта — код символа и цвет?

Выполняем movsw по 79 раз для каждой строки и в результате сдвигаем все строки экрана влево.

Рисуем подобие почвы (зеленая полоска) и затем дома. Чтобы было красивее и динамичней,этажность домов (один или два) выбираем случайно. Не то чтобы прямо случайно, но примерно случайно. За «случайным» числом обращаемся к микросхеме системного таймера ( in al, 0x40), которая при каждом обращении выдает новое число. Отталкиваясь от этого значения, мы рисуем либо одноэтажный домик, либо двухэтажный.

В псевдографике есть символ, который похож на стену с окошком, — 0x08. Вот его мы и используем. Крышу рисуем символом треугольника (0x1E).

MoveScene: рисуем трубу

При каждом сдвиге игрового поля счетчик next уменьшаем на единицу. Когда в счетчике оказывается число 3, 2, 1 или 0 — самое время рисовать трубу. Когда счетчик равен трем, выбираем «случайное» положение для дырки в трубе: число от 4 до 11.

Отталкиваясь от того, какое число в next (3, 2, 1 или 0), выбираем, какой символ псевдографики рисовать. Правый край отрисовываем редкой сеточкой (0xB0), левый — плотной сеточкой (0xB1), середину — сплошным цветом (0xDB).

Столбик за столбиком отрисовываем всю трубу. Начинаем с первой строки справа (нулевую пропускаем) и рисуем верхнюю часть трубы (CX — счетчик для hole). Затем рисуем тонкую линию под верхней частью трубы (0xC4). Пропускаем шесть строчек — чтобы образовалась достаточная для пролета дырка. Рисуем толстую линию над нижней частью трубы (0xDF). И наконец, нижнюю часть.

Чем больше труб уже нарисовано, тем меньше расстояние делаем до следующей. Но не меньше 17 символов. Если игрок оказался уж очень проворным и трубы у него пошли совсем близко, то не даем им рисоваться вплотную друг к дружке: достигнув значения 17, счетчик next больше не уменьшается.

Сигнатура загрузочного сектора

Загрузочный сектор в машинах IBM PC хранит 510 байт. Два недостающих байта от 512 зарезервированы под сигнатуру: 0x55, 0xAA. Считывая загрузочный сектор, BIOS ищет эту сигнатуру в его двух последних байтах. Ее наличие означает, что в загрузочном секторе записана программа, которую надо выполнить.

На древних досовских дисках эта программа парсила файловую систему FAT, чтобы найти там два файла: io.sys и msdos.sys. Затем программа загружала io.sys, который, в свою очередь, загружал msdos.sys.

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

РЕКОМЕНДУЕМ:
Скрытые игры и пасхалки Google

И напоследок — пара организационных моментов.

  • Для компиляции программы используй nasm: nasm -f bin flobird.asm -o flobird.com.
  • Если боишься редактировать бутсектор, можешь играть в Floppy Bird через эмулятор DOS. Например, DOSBox. Но только учти, что тот трюк, который мы предприняли для генерации случайных чисел, не работает в эмуляторе, и поэтому «случайная» этажность домов получается ну совсем не случайной.
Понравилась статья? Поделиться с друзьями:
Добавить комментарий