Создание игр для NES на ассемблере 6502: движение спрайтов

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

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!


Оглавление

Оглавление


Часть I: подготовка


  • Введение
  • 1. Краткая история NES
  • 2. Фундаментальные понятия
  • 3. Приступаем к разработке
  • 4. Оборудование NES
  • 5. Знакомство с языком ассемблера 6502
  • 6. Заголовки и векторы прерываний
  • 7. Зачем вообще этим заниматься?
  • 8. Рефакторинг

Часть II: графика


  • 9. PPU
  • 10. Спрайтовая графика
  • 11. Язык ассемблера: ветвление и циклы
  • 12. Циклы на практике
  • 13. Графика фона
  • 14. Движение спрайтов
  • 15. Скроллинг фона

14. Движение спрайтов


Содержание:

  • Zero-Page RAM
  • Подпрограммы
  • Управление регистрами для подпрограмм
  • Наша первая подпрограмма: отрисовка игрока
  • Соединяем всё вместе
  • Домашняя работа

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

Во-первых, мы больше не можем жёстко задавать позиции спрайтов в ROM картриджа.

[Напомню, что «ROM картриджа», или PRG-ROM — это часть нашей игры только для чтения, в схеме распределения памяти NES расположенная в диапазоне от $8000 до $ffff. Весь код игры находится здесь, однако он может ссылаться на адреса памяти за пределами PRG-ROM, например когда мы записываем спрайтовые данные в $0200-$02ff.]

Существует множество вариантов мест для хранения информации спрайта, но лучшим является «zero-page RAM».

Zero-Page RAM


«Страница» (page) памяти NES — это непрерывный блок из 256 байт памяти. Для любого адреса памяти старший байт определяет номер страницы, а младший байт определяет конкретный адрес внутри страницы. Например, диапазон от $0200 до $02ff — это «страница $02», а диапазон от $8000 до $80ff — «страница $80».

Тогда что же такое «zero-page RAM»? Нулевая страница (page zero) — это диапазон памяти от $0000 до $00ff. Удобной для хранения позиций спрайтов нулевую страницу делает её скорость. Процессор 6502 имеет специальный режим адресации при работе с zero-page RAM, делающий операции с адресами нулевой страницы гораздо быстрее, чем те же операции с другими адресами памяти. Для использования адресации нулевой страницы при указании адреса памяти применяется не два, а один байт. Давайте рассмотрим пример:


[Стоит заметить, что для использования преимуществ режима адресации нулевой страницы необходимо использовать только один байт. Ассемблер ничего не знает о передаваемых через него адресах памяти. Если в ассемблерном коде вы введёте LDA $003b вместо LDA $3b, то получившийся машинный код будет использовать абсолютный (более медленный) режим, хотя и загружаемый вами адрес памяти находится на нулевой странице.]

Итак, использование адресации нулевой страницы даёт нам очень быстрый доступ к 256 байтам памяти. Эти 256 байт являются идеальным местом для хранения значений, которые игра должна обновлять или ссылаться на них быстро, поэтому они походят для записи таких вещей, как текущий счёт, количество имеющихся у игрока жизней, текущий уровень или этап, а также позиции игрока, врагов, снарядов и т. п.

[Обратите внимание, что я сказал «позиции игрока», а не позиции отдельных тайлов, из которых состоит игрок. Любой разработчик, надеющийся поместить в свою игру достаточно большое количество одновременно отображаемых экранных объектов, должен тщательно ограничивать использование адресов нулевой страницы.]

Давайте начнём применять zero-page RAM в своём коде. Так как единственные адреса от $8000 и далее — это ROM (т. е. часть картриджа/кода, который вы пишете), мы не можем просто напрямую записывать значения нулевой страницы. Вместо этого мы приказываем ассемблеру зарезервировать память на нулевой странице следующим образом


[Обратите внимание на ":" после имени каждого зарезервированного байта — это похоже на уже используемые нами метки, потому что это они и есть! Когда мы позже в коде используем имя зарезервированного байта, то приказываем ассемблеру найти адрес памяти, соответствующий этой метке и заменить имя адресом, точно так же, как и с любой другой меткой в коде.]

Сначала мы сообщаем ассемблеру, что хотим зарезервировать память нулевой страницы, воспользовавшись соответствующим сегментом из файла конфигурации компоновщика — в данном случае это "ZEROPAGE". Затем для каждого диапазона памяти, который мы хотим зарезервировать, используем директиву .res, за которой следует количество байтов, которое нужно зарезервировать. Обычно это будет «1» для резервирования одного байта памяти, но возможность указания любого числа может быть полезной если, например, нам нужно сохранить на нулевой странице 16-битное число.

Теперь, когда у нас есть зарезервированная память, нам нужно инициализировать её хорошим начальным значением где-нибудь в коде. Два подходящих варианта — это обработчик сброса или начало main. Мы выберем решение с обработчиком сброса. В файле reset.asm, прямо перед JMP main, добавим следующий код:


Однако если попробовать ассемблировать этот код (ca65 src/reset.asm), то мы получим ошибку:

Error: Symbol 'player_y' is undefined
Error: Symbol 'player_x' is undefined

В общем случае зарезервированные имена памяти действительны только в том файле, где они были заданы. В данном случае мы зарезервировали player_x и player_y в основном файле, но пытаемся использовать их в reset.asm. К счастью, у ca65 есть директивы для экспорта и импорта зарезервированной памяти нулевой страницы, поэтому эту информацию можно передавать между файлами. Нам достаточно добавить в основной файл директиву .exportzp:


Затем в reset.asm мы можем воспользоваться директивой .importzp:


[Директива .importzp должна находиться внутри .segment "ZEROPAGE", даже если в этом файле вы больше ничего не делаете со значениями нулевой страницы.]

При ассемблировании этих файлов ca65 просматривает остальные файлы исходников в той же папке в поисках импорта и экспорта, а затем разбирается, где какие данные должны находиться.

Подпрограммы


Так как количество адресов нулевой страницы ограничено (всего 256), там нужно тщательно продумывать их использование. Вместо хранения позиции каждого отдельного тайла спрайта игрока (для чего потребовалось бы 8 байт нулевой страницы), мы будем хранить только общие координаты X и Y игрока, а отрисовку самих спрайтов игрока перенесём в подпрограмму (subroutine). Подпрограммы — это ассемблерная версия функций: именованные, многократно используемые фрагменты кода.

Для создания подпрограммы нужно добавить в код новую .proc. Единственное требование к подпрограмме заключается в том, что она должна заканчиваться опкодом RTS, «Return from Subroutine» («возврат из подпрограммы»). Для вызова подпрограммы используется опкод JSR, «Jump to Subroutine» («переход к подпрограмме»), за которым следует имя подпрограммы (то, что идёт за .proc).

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


При запуске этого кода процессор сначала помещает в накопитель литеральное значение $80. Затем он вызывает подпрограмму do_something_else. Когда 6502 видит опкод JSR, он записывает текущее значение счётчика программы (специального регистра, в котором хранится адрес памяти следующего обрабатываемого байта) в стек. В компьютерных науках стеком называется структура данных вида «последним вошёл, первым вышел», похожая на стопку тарелок. При добавлении чего-то в стек это помещается наверх стопки, и всегда доступен только самый верхний элемент стопки.

В процессоре 6502 стек имеет размер 256 байт и расположен в диапазоне от $0300 до $03ff. 6502 использует специальный регистр «stack pointer» («указатель стека»), часто сокращаемый до «S», указывающий, где находится «вершина» стека. При первой инициализации системы в указателе стека хранится значение $ff. Каждый раз, когда в стек что-то сохраняется, это записывается в $0300 плюс адрес, хранимый в указателе стека (например, первая запись в стек хранится в $03ff), а затем выполняется декремент указателя стека на единицу.

[Попытка одновременной записи более чем 256 элементов в стек приводит к возврату указателя стека с $00 к $ff, то есть дальнейшие операции записи в стек будут перезаписывать уже существующие в стеке данные. Эта обычно приводящая к катастрофе ситуация называется переполнением стека (stack overflow) (хотя в случае 6502 правильнее назвать её исчерпанием стека, stack underflow).]

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

Итак, в строке 2 процессор видит опкод JSR и сохраняет текущее значение счётчика программы в стек. Затем он берёт операнд опкода JSR и помещает этот адрес памяти в счётчик программы. Далее процессор перескакивает из строки 2 в строку 6 и записывает в накопитель литеральное значение $90. Следующий опкод — это RTS. Когда 6502 видит RTS, он берёт «верхнее» значение из стека (это часто называется «извлечением» (pop) элемента из стека) и помещает его в счётчик программы. Учитывая способ работы стека, это должен быть адрес, который был записан (push) в стек, когда процессор встретил опкод JSR. Это возвращает нас к коду, идущему непосредственно за JSR. В нашем случае это будет STA $8000 — и результат будет записывать в этот адрес $90, а не $80. По умолчанию подпрограммы не «сохраняют» значения регистров ни при их вызове, ни при возврате из них. В большинстве языках более высокого уровня это решается при помощи концепций наподобие «область видимости переменных» (variable scope) или «время жизни» (lifetime). Однако в ассемблере вы сами должны заниматься сохранением и восстановлением состояния всех регистров (в том числе и регистра состояния процессора!), если вам нужно, чтобы при возврате из подпрограммы эти значения оставались теми же.

[В общем случае, когда задействованы подпрограммы, всегда стоит сохранять и восстанавливать регистры. Прерывания типа NMI или IRQ могут вызываться в любой момент, даже когда вы находитесь внутри другой подпрограммы! — и может быть сложно предсказать, какое значение находится в регистре, если ваши подпрограммы/обработчики прерываний не написаны «безопасным» образом.]

Управление регистрами для подпрограмм


Для сохранения и восстановления содержимого регистров у 6502 есть четыре опкода: PHA, PHP, PLA и PLP. PHA и PHP используются для записи (push) накопителя («A») и регистра состояния процессора («P») в стек. В обратном направлении PLA и PLP извлекают (pull) верхнее значение из стека и помещают их в накопитель или в реестр состояния процессора. Для регистров X и Y специальных опкодов нет; для записи их значений в стек нужно сначала передать их в накопитель (с помощью TXA / TYA), а для восстановления нужно извлечь их из стека в накопитель и снова передать (при помощи TAX / TAY).

Давайте рассмотрим пример подпрограммы, использующей эти новые опкоды:


При вызове my_subroutine (с помощью опкода JSR my_subroutine) первые шесть опкодов сохраняют состояние регистров в стек, прежде чем делать что-то другое. Первым идёт PHP, сохраняющий состояние регистра состояния процессора, потому что регистр состояния процессора обновляется после каждой команды — если бы мы ждали до конца, чтобы сохранить P, то он с большой вероятностью был бы изменён результатами выполнения таких команд, как TXA. Сохранив регистр состояния процессора в стек, далее мы записываем в стек значение аккумулятора, а затем переносим и записываем в стек значения регистров X и Y. Сохранив всё в стек, мы можем спокойно использовать все регистры 6502, не беспокоясь о том, что вызывавший нашу подпрограмму код ожидает найти в них. После завершения кода подпрограммы мы возвращаем обратно всё, что сохранили в начале. Всё восстанавливается в обратном порядке — сначала извлекаются и перемещаются регистры Y и X, затем накопитель, а потом регистр состояния процессора. Заканчиваем мы опкодом RTS, возвращающим поток программы в точку, в которой мы вызвали подпрограмму.

[Если вы забудете добавить RTS в конец подпрограммы, то 6502 не выполнит возврат к тому месту, откуда была вызвана подпрограмма, и вместо этого спокойно продолжит следующим байтом после кода подпрограммы. Процессор ничего не знает о .proc, это просто инструменты, помогающие вам упорядочивать свой код.]

Наша первая подпрограмма: отрисовка игрока


Теперь, когда мы узнали, как работают подпрограммы, настало время написать свою собственную. Давайте напишем подпрограмму, отрисовывающую корабль игрока в выбранном месте. Для этого нам нужно использовать переменные нулевой страницы player_x and player_y, которые мы создали ранее для записи соответствующих байтов диапазон памяти $0200-$02ff. Ранее мы делали это при помощи сохранения соответствующих байтов в RODATA и копированием их в цикле, а также индексированной адресации, как и в случае палитр. Напомню, что нам нужно записывать по четыре байта данных для каждого спрайтового тайла размером 8 на 8 пикселей: позицию спрайта по Y, номер тайла, специальные атрибуты/палитру и позицию по X. Номер тайла и палитра для каждого из четырёх спрайтов, составляющих корабль игрока, не меняются, поэтому начнём с этого. Также мы будем сохранять и восстанавливать регистры системы в начале и конце подпрограммы.


Для корабля игрока используются тайлы $05 (верхний левый), $06 (верхний правый), $07 (нижний левый) и $08 (нижний правый). Мы записываем эти номера тайлов в адреса памяти $0201, $0205, $0209 и $020d, потому что они соответствуют «байту 2» первых четырёх спрайтов. Во всех тайлах корабля игрока используется палитра ноль (первая палитра), поэтому код для записи атрибутов спрайтов гораздо короче. $0202, $0206, $020a и $020e — это байты, за которыми непосредственно следуют байты предыдущего номера тайла, поэтому они хранят атрибуты для каждого из первых четырёх спрайтов. Далее мы восстанавливаем все регистры в порядке, обратном порядку сохранения, и используем RTS для завершения подпрограммы.

А что насчёт местоположения каждого тайла на экране? Для этого нам нужно использовать player_x, player_y и простую математику. Чтобы упростить, давайте допустим, что player_x и player_y задают верхний левый угол верхнего левого тайла корабля игрока. В обработчике сброса мы разместили верхний левый угол верхнего левого тайла корабля игрока в координатах ($70, $a0). разместив верхний левый тайл, мы можем прибавить восемь пикселей к player_x и player_y, чтобы найти позиции остальных трёх тайлов. Вот как это выглядит (от предыдущего кода оставлены одни комментарии):


Помните, что когда вам нужно выполнить сложение, сначала надо вызвать CLC, а затем использовать ADC (если только вы не пытаетесь прибавить что-то к 16-битному числу). Результат сложения находится в накопителе; он не записывается в player_y или в player_x.

Соединяем всё вместе


Написав подпрограмму, мы можем теперь ею воспользоваться. Мы уже задали исходные значения player_x и player_y в обработчике сброса. Теперь мы вызовем новую подпрограмму в рамках обработчика NMI, чтобы она выполнялась в каждом кадре:


Заметьте, что мы выполняем DMA-передачу того, что уже находится в диапазоне памяти $0200-$02ff, прежде, чем вызвать подпрограмму. На выполнение обработчика NMI есть очень мало времени, поэтому поместив DMA-передачу в начало, мы гарантируем, что в каждом кадре на экране будет отрисовываться хотя бы что-то.

Далее нам нужно обновлять в каждом кадре player_x, чтобы спрайты перемещались по экрану. В этом примере мы оставим значение player_y таким же, но будем изменять player_x, чтобы корабль игрока двигался вправо, пока не приблизится к правому краю экрана, а затем двигался влево, пока не приблизится к левому краю. Чтобы упростить задачу, нам нужно хранить направление, в котором движется игрок. Давайте добавим ещё одну переменную нулевой страницы с именем player_dir. Значение 0 будет означать, что корабль игрока движется влево, а 1 — что корабль игрока движется вправо.


Я не экспортировал переменную player_dir, потому что другим файлам не нужен доступ к ней (пока). Теперь можно написать код для обновления player_x. Мы можем написать этот код непосредственно в обработчике NMI, но предчувствуя, что в дальнейшем движение игрока будет более сложным, поместим его в отдельную подпрограмму update_player:


В этой подпрограмме активно используются опкоды ветвления и сравнения, которые мы видели в Главе 11. Сначала мы загружаем player_x в накопитель и сравниваем с $e0. CMP, как мы узнали ранее, вычитает свой операнд из накопителя, но устанавливает только флаги переноса и нуля. Мы можем использовать получившиеся флаги регистра состояния процессора, чтобы определить, что значение в накопителе (в данном случае это player_x) больше, равно или меньше операнда CMP. BCC not_at_right_edge приказывает 6502 перескочить к not_at_right_edge, если сброшен флаг переноса. При выполнении вычитания в рамках сравнения 6502 сначала задаёт флаг переноса, и сбрасывается он только тогда, когда накопитель меньше операнда CMP. В данном случае, если накопитель меньше $e0, то мы знаем, что не находимся рядом с правым краем экрана, поэтому можем перескочить к not_at_right_edge. Если накопитель больше $e0, то будет установлен флаг переноса и 6502 продолжит со следующей строки. В этом случае мы рядом с правым краем экрана, поэтому присвоить player_dir значение 0 (обозначающее «движение влево»). Затем мы используем JMP, чтобы пропустить проверки того, находимся ли мы рядом с левым краем экрана, потому что уже знаем, что это невозможно.

Если в результате первого сравнения выяснилось, что player_x не рядом с правым краем экрана, то дальше нужно протестировать, находится ли player_x рядом с левым краем экрана. Мы сравниваем player_x с $10, и на этот раз используем BCS direction_set. Как говорилось выше, BCS срабатывает, если накопитель (player_x) был больше, чем сравниваемое значение ($10). В данном случае мы не рядом с левым краем и можем перейти вперёд, к изменению player_x. В противном случае, нам нужно присвоить player_dir значение $01, что обозначает «движение вправо».

[Обратите внимание, что из-за того, как устроена update_player, если корабль игрока не рядом ни с одним краем экрана, то player_dir не будет изменяться, но будет выполняться соответствующий инкремент или декремент player_x.]

Наконец, настало время воспользоваться результатами проверок близости к краям. Мы сравниваем player_dir с $01 и смотрим, равен ли результат нулю. Если да, то задействуется BEQ move_right и мы выполняем инкремент player_x. В противном случае происходит декремент player_x. Выполнив обновление, мы восстанавливаем все регистры и возвращаемся из подпрограммы.

Давайте вызовем подпрограмму внутри обработчика NMI, чтобы завершить пример проекта:


Осталось только ассемблировать и скомпоновать файлы в ROM консоли NES:

ca65 src/spritemovement.asm
ca65 src/reset.asm
ld65 src/reset.o src/spritemovement.o -C nes.cfg -o spritemovement.nes

Если вы откроете получившийся файл .nes в эмуляторе, то должны увидеть следующее:


Домашняя работа


Теперь, когда вы разобрались с основами перемещения спрайтов по экрану, попробуйте реализовать эти проекты, чтобы углубить своё понимание.

  • Сделайте так, чтобы корабль игрока двигался быстрее. На данный момент мы перемещаем корабль игрока со скоростью один пиксель в кадр, или 60 пикселей в секунду. Как ускорить это движение?
  • Вместо player_x изменяйте значения player_y (или одновременно). Помните, что позиции спрайта по Y больше $e0 будут находиться ниже края видимой области экрана.
  • Что произойдёт, если вместо проверки левого и правого краёв экрана мы просто будем в каждом кадре выполнять INC player_x (или DEC player_x)? Это достаточно простое действие и его можно реализовать непосредственно в обработчике NMI, даже не касаясь update_player.
  • Добавьте дополнительные спрайты и перемещайте их отдельно от спрайта корабля игрока.

Весь код из этой главы можно скачать здесь.
Источник: https://habr.com/ru/post/599433/


Интересные статьи

Интересные статьи

В предыдуще статье мы рассмотрели создание TabBarController и NavigationController программно в UIKit. В данной статье мы продолжим выполнение заданий и рассмотрим пункты 6 и 7. А Bar Button Item и Al...
Приветствую! Меня зовут Михаил, я разработчик Oracle в ClubPro (Клубная программа, программа лояльности Спортмастера). В команде разработки моё основное направление связано с развитием Campaign Manage...
Настройка любой площадки для CMS — это рутинный процесс, который должен быть доведен до автоматизма в каждой уважающей себя компании. А потому частенько воспринимается, как восход солнца — это происхо...
При масштабной работе с Apache Kafka вы рано или поздно столкнетесь с проблемой доступного дискового пространства, темпами роста тем или общими вопросами использования ди...
Привет, Хабр! Мы продолжаем серию материалов, посвященных продуктовому менеджменту. В этом посте мы обсудим, как продуктовый менеджер определяет что попадет в дорожную карту и как зан...