Shooter
В прошлом номере Shrek писал об FPGA-клонах и при ZX Uno упомянул, что EsxDOS годится только для геймеров и что умеет сохранять лишь на TRDos. Попробуйте ту статью перечитать. Мы уже не в 90-х, и статьи такого качества действительно могли бы не появляться. Мой первоначальный замысел был написать уничтожающую статью о качестве журналистики Shrek’а. Но тут речь о Shrek’е, который воспринял бы это как «ну я тупой, ладно» и просто не сделал бы выводов. Поэтому возникла мини-игра Shooter — как наглядная демонстрация, что EsxDOS действительно можно использовать для серьёзной работы.
Игра полностью запрограммирована на ZX Spectrum, в EsxDOS и без TRDos (потому что тот у меня всё равно не работал бы — я запускаю EsxDOS из MAPRAM), и вы можете загрузить её как приложение к текущему номеру Restore.
В качестве ассемблера я использовал Prometheus, потому что ничего лучше на Spectrum просто нет. По крайней мере для меня. Но есть недостаток: Prometheus не умеет сохранять напрямую в EsxDOS, так что приходится сохранять образ памяти из Basic. На CF-карту те 41 кБ у меня помещаются, так что это не препятствие. Я написал для этого короткую basic-программу с меню, в которой могу сохранить ассемблер, перезагрузить с диска, вернуться в basic или загрузить музыку и графику и запустить игру. Программу сохранил под простым именем «l», так что для загрузки достаточно короткой команды LOAD *"l".
Тут упомяну, что EsxDOS использует синтаксис, похожий на D40, но поскольку данные сохраняет на FAT16/32, в начало файла пишет заголовок +3DOS — подобно ZX Spectrum +3. При переносе данных между EsxDOS и PC поэтому полезен пакет утилит TAPTOOLS от John Elliott (https://www.seasip.info/ZX/unix.html), особенно утилиты SPECFORM и UNSPECFORM для добавления и снятия заголовка +3DOS.
В начале разработки вся программа вместе с графикой помещалась в память сразу, но по мере расширения графики пришлось начать перезаписывать Prometheus данными игры, и другого способа запускать игру, кроме как через basic-loader, по сути не было. Я упоминал, что игра запрограммирована чисто на ZX Spectrum, но это не совсем правда. Для лого я подготовил 3 эффекта и те написал на Помощном Calculátor’е, а уже одобренный ZeroTeam эффект переписал в Prometheus. Также графику делал CVM, прислал мне по email, а всякие спрайты и вырезки экрана я подготовил уже на PC. Большинство картинок к тому же запаковано пакером mhmt (https://github.com/lvd2/mhmt), а чтобы не гадать, где на экране какой текст или атрибут, я написал просмотрщик ZX-картинок для XWindow, который выводит адреса в videoram под курсором. Найдёте его в ZXSpectrum-utils (https://zxspectrumutils.sf.net — секция Code) под именем xzxs. А музыку в конце концов переписал из одного .xm в Beepola. Извиняюсь за фальшивые ноты — другого источника звуков у меня не было.
При создании я использовал почти всё ZX-железо, что было дома. Основа — divIDE, которое я одно не менял. Иначе использовал свою +2, которая отличная. У неё великолепная клавиатура, красивая картинка — придраться не к чему. Попробовал и оба своих резиновых. Один — issue2, второй скрывает плату Harlequin. Harlequin — суперстабильная машина с прекрасным композитным выходом, и issue2 тоже не имел проблем. Но это был первый раз, когда я писал что-то длинное на резиновой клавиатуре, и должен подтвердить: это не приятный опыт. Для игр или программирования на Basic хватает, но в Prometheus уже чувствовал усталость пальцев. Несмотря на это, через них запрограммировал довольно много кода.
Далее — Gamy, у меня две. Чтобы избежать недоразумений, упомяну, что у обеих перемазаны клавиатуры. То, чего в Didaktik не делали, но без чего этими клавиатурами пользоваться нельзя. Первая Gama — 1988 года, то есть ещё со sinclair-овским шрифтом и вертикальными полосами в картинке. Вторая gama переделана на 192 кБ, так что расширенная память по образцу 128-ки (и чуть больше), встроенный AY и прекрасная картинка. На Game я вырос, и это мой любимый компьютер. Клавиатура у меня в пальцах настолько, что не нужно смотреть на клавиши, и как уже писал — при небольшой заботе клавиатура отличная. Вероятно, большая часть кода была написана именно на одной из Gam. Раз уж о didaktik’ах — должен упомянуть и M. У меня одно, переделанное на 128-ку от Kompakt Servis, и работа на нём была одним большим сюрпризом. У него ведь ПРЕВОСХОДНАЯ клавиатура. По сравнению с остальными машинами — большие, эргономично сформованные клавиши, и печать — удовольствие. Также курсорные клавиши очень привыкаемые. С другой стороны, у M проблема с отображением цветов, и никогда не знаешь, будет ли после включения картинка цветной или нет.
Последний компьютер, который я использовал, — Delta. Слышал, что многим layout «плюсов» не подходит, но мне он нравится. Только Edit как-то любит нажиматься сам. На Delta я в основном только багфиксил кое-что, так что долгого опыта с ней нет. Пишется на ней хорошо, очень помогают формованные клавиши, но качества механической клавиатуры не достигает.
А зачем я это, собственно, пишу? Потому что программирование на оригинальном железе я действительно насладился. Хотя уже привык к кросс-компиляции на PC, при программировании не наткнулся на какое-то свойство, которого мне откровенно не хватало. Это было как дуновение старых добрых времён, когда ты лупил по D40, потому что эта зараза опять угробила дискету при сейве… Что я пишу. Это лучше, чем когда-либо раньше! Проблемы меня обходили, CF-карта ничего не забыла, и я мог сосредоточиться только на коде. Главный недостаток — число отображаемых строк на экране. Поэтому в один момент я подключил к Game принтер и распечатал исходник. Это была отличная помощь при поиске проблем в коде и также источник заметок — потому что я старался избегать комментариев в ассемблере, чтобы сэкономить память.
Хотел бы поблагодарить Dušky за помощь с отладкой. Особенно вторая клавиша для выстрела дала мне прикурить. Также спасибо Baze за идею с подсчётом ranking. И CVM — за тонны графики, замечаний, идей и тестирование.
О самой игре много не напишу. Инструкции в игре, и твёрдо верю, что вам понравится. Управление — либо Sinclair, либо Kempston joystick, либо клавиатурой через QAOP и M или Space для выстрела. У бета-тестеров были проблемы со сложностью игры, но я думаю, что после небольшой тренировки это не так уж трудно. Вот несколько моих советов для лучшего результата:
- Запомните очертания мишеней. Даже когда перекрываются — их можно распознать.
- На более высоких уровнях возвращайте курсор в верхний прицел. Маленькие мишени тогда легче наводить.
- Стреляйте в верхнюю часть мишеней — успеете предотвратить столкновение с выдвигающейся мишенью.
- Не паникуйте. Лучше дать злодею уйти, чем попасть в неправильную мишень.
И, разумеется, похвастайтесь редакции Restore своим score и ranking. Имена лучших игроков появятся в следующем номере!
mike/zeroteam
Shooter — исходный код
В приложении найдёте Prometheus с исходным кодом игры, и если захотите посмотреть, как игра сделана, вот несколько заметок, которые должны облегчить ориентацию. В основном буду ссылаться на метки, поэтому принесите второе Spectrum и на нём запустите Prometheus, чтобы было ясно, о чём пишу. Prometheus можно загрузить командой LOAD *"l".
Начнём со структур данных. В игре 10 уровней, и они описаны в таблице с меткой LEVELS. Каждый уровень определён 5 байтами:
- ненулевое значение определяет, будут ли отображаться враги также в верхнем «глазке»
- ненулевое значение означает, что будут генерироваться и гражданские
- таймер, определяющий, как часто будут генерироваться новые мишени
- число одновременно отображаемых мишеней
- число врагов, нужных для завершения уровня
Байты 3 и 4 определяют поведение таймеров в массиве TIMER. Здесь генерируется число таймеров по байту 4 — то есть сколько мишеней может быть максимум отображено одновременно. Они каждую петлю игры уменьшаются, и при достижении значения 0 генерируется новая мишень. Затем таймер снова ставится на значение из пункта 3 плюс случайное число в диапазоне 0–15. При инициализации нового уровня (рутина INITLVL) время таймера делится на число мишеней, и это значение используется как шаг для генерации значений таймеров. Тем самым обеспечивается, что мишени будут генерироваться равномерно распределёнными во времени и не случится ситуация, когда разом генерируется несколько мишеней, а потом смотришь на пустую стену. Ещё одна интересная таблица — SPR_TAB1 до SPR_TAB3. Здесь генерируются мишени для каждой «линии». SPR_TAB1 — верхняя на земле, SPR_TAB2 — нижняя на земле, SPR_TAB3 — глазок. У каждой линии 3 позиции (т.е. максимум мишеней в одной плоскости — 3) размером 7 байт:
- status — 0 означает, что на этой позиции нет мишени, 1 — мишень выдвигается, 2 — ожидание перед 3, когда мишень заходит
- это таймер: для фаз 1 и 3 означает, сколько строк спрайта отобразится, для фазы 2 убывает от 25 до нуля
- и 4. байт содержит адрес спрайта
- и 6. байт содержит адрес в videoram, куда будет рисоваться спрайт
- байт содержит цвет мишени, зависящий от освещения, либо сюда сохраняется зелёный/красный цвет как индикация попадания в мишень
Эта таблица вместе с отрисовкой обновляется каждое 4-е прерывание в рутине UPGR_SPR, которая вызывается в главном цикле игры GAMELOOP.
Ещё одна интересная ячейка памяти — ZBUF. Как следует из названия, сюда сохраняется значение, с какой линии рисуется спрайт. Тем самым задние спрайты не перерисовываются через передние.
Я решил это так прежде всего потому, что у меня нет никакого видеобуфера и всё рисую прямо в videoram. Помимо самой глубины сюда сохраняется и информация, какой спрайт из SPR_TABx здесь находится. Кодируется так: 000AABBB где: AA — номер спрайта в линии (0–2), BBB — номер линии (0–2)
Почему у BBB три бита — уже не помню. Наверняка думаете, почему это нельзя закодировать как один индекс в таблицу SPR_TAB1. Потому что значение BBB служит и как глубина Z и определяет, какой спрайт будет на переднем плане, а какой на заднем. При проверке выстрела ZBUF используется, чтобы узнать, есть ли на позиции курсора спрайт, и если да — можно вычислить индекс в SPR_TABx. Это быстрее, чем проходить таблицу спрайтов и проверять, находится ли координата курсора в прямоугольнике отрисованного спрайта. Проверку fire можете посмотреть в рутине FIRE_UPD.
Ещё опишу несколько переменных, служащих для управления игрой: SHELLS — число патронов, для каждого уровня инициализируется на 38; LEVEL — номер текущего уровня; LVL_LIM — число врагов, нужных для завершения уровня; LVL_KILL — число убитых гражданских; LVL_HITS — число убитых террористов; LVL_MISS — число выстрелов, ничего не задевших; LVL_GEN — число сгенерированных террористов; LVL_UNG — число ушедших террористов; LVL_UNK — число ушедших убитых террористов; TARGETS — число мишеней на экране; SUM_KILL, SUM_HITS, SUM_MISS, SUMUNK — сумма LVL* за всю игру для итоговой статистики; SCORE — счёт :-) ; H_SCORE, H_KILL, H_HITS, H_MISS, H_UNK — high score со статистикой
И наконец добираемся до описания игры. В начале программы после инициализации и эффекта с лого попадаем в главное меню (метка MAINMENU). Здесь можно выбрать управление, которое в рутине SET_KEYS копирует предопределённые данные в таблицу управления. Иначе через 250 прерываний переключается на экран с high score (рутина TITLE2) и ещё через 250 прерываний или по нажатию клавиши — обратно в главное меню.
Сама игра начинается меткой START, и после инициализации переменных и распаковки картинки с игровым полем на адрес FIELDSCR отображается игровое поле и инициализируется уровень в рутине NEXTLVL. Следует главный цикл игры GAMELOOP. Первым выводится score (P_SCORE). Затем оценка выстрела рутиной FIRE_UPD. Настоящая проверка на fire — в рутине CONTROLS, но она лишь ставит флаг выстрела AIM_FIRE и позицию курсора AIM_ADR. Изначально я проверял клавиатуру в прерывании и не хотел иметь там довольно длинную рутину оценки выстрела. В итоге всё выполняется вне прерывания, и это реликт прошлого. Подобных странностей там больше. Обратно к FIRE_UPD. В начале проверяются обе fire-клавиши, и если какая-то нажата — рутина заканчивается. Это обеспечивает, что сбитие мишени происходит при отпускании клавиши выстрела, а не при нажатии. Далее — проверки активных зон окружения. Затем уменьшается число выстрелов в переменной SHELLS и снимается атрибут в нижнем индикаторе патронов. Смотрим в ZBUF, и если мы не над мишенью — прыжок на C_FIRE_M, которая рисует дыру в стене. Если попали в мишень — по значению в ZBUF вычисляется адрес в таблице SPR_TABx, и если попали в уже однажды поражённую мишень — снова прыжок на C_FIRE_M. Иначе обновляется позиция в SPR_TABx, играется звук выстрела и обновляется score.
Экран по адресу FIELDSCR служит для восстановления графических данных под курсором и мишенями. Поэтому, например, выстрелы в стену нужно рисовать и в videoram, и в этот запасной экран. При новом старте игры сюда снова распаковывается свежий фон, так что выглядит, будто рабочие перед каждой игрой заново штукатурят стены.
Далее вызываются рутины AIM_CLS и AIM_DRAW. Они относительно простые. Единственный подвох — у AIM_CLS, которая проверяет в ZBUF, не стирает ли случайно область, где находится мишень. Если да — обновляет регистры и обходит рутину стирания курсора (например, AIM_C_S2). Мог бы написать, что причина, разумеется, скорость — но это не совсем правда. Изначально мишени тоже рисовались каждое прерывание, так что искать спрайт мишени и считать его образец с учётом позиции выдвижения/захода было бы из пушки по воробьям. Поскольку отрисовка мишеней в каждом прерывании конфликтовала с лучом и в некоторых частях картинки курсор пропадал, я поправил отображение на каждое 4-е прерывание. Это, разумеется, имеет больший смысл, потому что позиции мишеней и так обновлялись раз в четыре прерывания. Следствие — если провести курсором через мишень, на ней появляется его тень. Но верю: если бы я вам не сказал, вы бы и не заметили.
Следует обслуживание клавиатуры/джойстика. Рутина CONTROLS проходит таблицу M_LEFT, читает адрес рутины для нажатого направления/выбора, порт, маску и значение активного состояния. Это важно потому, что клавиатура+Sinclair активны в нуле, тогда как Kempston joystick активен в 1. Сами рутины движения курсора проверяют края и ставят новую позицию AIM_POS. Ничего сложного.
Зато от метки UPGR_TIM следуют самые сложные рутины всей игры. Сначала сравниваю таймер, и если не настало то самое время — отрисовочные рутины обхожу (на метку GEN1). Далее ставится текущая линия в переменной SPRT_LIN, и перед отрисовкой мишеней в рутине DRAW_SPR вызывается рутина SET_SPRT, которая ставит правильные константы размеров спрайтов. Ожидает в регистре A число 4 (ширину спрайта) для больших спрайтов на линиях 1 и 2 и значение 2 для глазочных спрайтов. Поскольку спрайты на линиях 1 и 2 одинаковые, эту рутину достаточно вызывать лишь дважды.
Отрисовка мишеней — работа для DRAW_SPR. После вводного цикла следует довольно длинная рутина, которую можно разделить на 3 части. Первая часть (между метками DRAW_SP1 и DRW_DEL1) вычисляет адрес в videoram и ZBUF. Между метками DRW_DEL1 и DRW_DEL4 — само стирание спрайта, и последняя часть, между метками DRW_LINE и DRW_LN3, рисует графику. Использую здесь свойство, что таймер в структуре SPR_TABx мне (кроме ожидания между выдвижением и задвижением спрайта) говорит, сколько строк рисовать. Это можете посмотреть в начале рутины с пометкой, что для state=2 рисуем 5 строк. Стирание спрайтов имеет смысл только если спрайт заходит — и тогда при каждой итерации отрисовки стирается одна строка спрайта. Это проверяется на метке DRW_DEL1. Наконец в цикле DRW_LINE рисуется графика спрайта.
Управление мишенями, т.е. их выдвижение, удержание и задвижение, выполняется в рутине UPGR_SPR. Рутина не особенно сложная — достаточно держаться описания SPRT_TABx. От метки DRW_M_0 — выдвижение мишени, DRAW_W8 — ожидание задвижения, DRW_M_DW — задвижение. В последней ещё вызывается функция TST_KILL, которая выясняет, была ли ушедшая мишень поражена, и соответственно обновляет статистику уровня. А если спросите, зачем там out’ы на порт FE — они делают то красивое пощёлкивание при выдвижении и задвижении мишеней.
Следует вызов рутины GEN_LINE. Эта рутина генерирует новую мишень в свободное место в структуре SPR_TABx. Как именно эта рутина работает, описывать не буду — это выше моих сил. Это самая переписываемая рутина, содержала больше всего ошибок, и я потратил над ней больше всего времени. Что мог бы упомянуть — почему рутина вызывается в таком несчастном цикле. Изначально она вызывалась разом для всех TIMER’ов, дошедших до значения 0. Но случалось, что к концу уровня обнулялись 2 TIMER’а и генерировалась на одну мишень больше лимита уровня — и тогда уже не работала проверка конца уровня. Теперь после первого найденного нулевого TIMER цикл заканчивается, и эта проблема не возникает. Все те push и pop можно было бы и убрать, но оставлю их там как реликт прошлого.
Следуют проверки конца уровня — и на этом мой короткий обзор исходного кода игры Shooter. Наверняка можно описать гораздо больше — например, частое использование BCD-арифметики, почему исходный код заканчивается nop’ом за A0LEN, или сохраняет значение A0 по адресу 23296. Эти вопросы уже оставлю вам — и надеюсь, что игра понравится вам и с точки зрения программиста.
mike/zeroteam
Поделитесь вашим мнением о статье