Marsmare: Alienation На конкурсе Yandex Retro Games Battle первое место занял платформер Marsmare: Alienation от команды Пьяная Муха (новички на Спектруме - с 2019 года!). Мы связались с автором кода этой игры - Николаем Запо─ льновым. * * * Alone> Полистал код Marsmare: Alienation в де─ багере и обнаружил там весьма необычный метод отрисовки. Какие примерно характери─ стики - сколько он поддерживает графики, сколько одновременно спрайтов на экране, есть ли клипирование (если есть, то как оно работает)? Николай Запольнов> Я изначально ориентировался на принцип, описанный в этой статье: http://oldmachinery.blogspot.com/2014/04/ zx-sprites.html Спрайты preshifted (и жрут кучу памяти, надо сказать, поэтому их пришлось сильно сжимать). Отрисовка идёт по таблице адресов (опи─ шу ниже принцип; отдельная таблица на каж─ дую ширину спрайта, по знакоместам: DrawRoutines8,DrawRoutines16 и 24 ),напри─ мер: DrawRoutines8: dw DirtyBits8 4 раза: dw Skip8 7 раз, SkipEnd8 3 раза: dw Draw8 7 раз, DrawEnd8 dw Draw8 7 раз, DrawBound8 7 раз: dw Draw8 7 раз, DrawEnd8 dw Draw8 7 раз, DrawBound8 7 раз: dw Draw8 7 раз, DrawEnd8 dw Draw8 7 раз, DrawBound8 24 раза dw DrawSprite@@done Пропускает одну строку (не кратную 8): Skip8: inc hl inc hl inc d ;Y0-Y2 += 1 ret Пропускает одну строку (кратную 8): SkipEnd8: inc hl inc hl ex de,hl add hl,bc ;BC = 0xf920 ;Y0-Y2 -= 7 ;Y3-Y5 += 1 ex de,hl ret Рисует одну строку и переходит к следующей строке (не кратной 8): Draw8: ld a,(de) and (hl) inc hl or (hl) inc hl ld (de),a inc d ;Y0-Y2 += 1 ret Рисует одну строку и переходит к следующей строке (кратной 8): DrawEnd8: ld a,(de) and (hl) inc hl or (hl) inc hl ld (de),a ex de,hl add hl,bc ;BC = 0xf920 ;Y0-Y2 -= 7 ;Y3-Y5 += 1 ex de,hl ret Рисует одну строку и переходит к следующей строке (на границе, где адресация экрана у Спектрума перепрыгивает): DrawBound8: ld a,(de) and (hl) inc hl or (hl) inc hl ld (de),a inc d ;before:Y0-Y2 = 7 ;after: Y0-Y2 = 0,Y6-Y7 += 1 ld a,e ;Y3-Y5 = 7 add a,c ;0x20 ld e,a ;Y3-Y5 = 0 ret Рутина отрисовки спрайта: 1) ставит SP на строку в таблице,соотве─ тствующую стартовой координате Y; 2) заменяет в строке в таблице для последней координаты Y адрес на DrawSprite@@done (сохраняя прошлое значе─ ние) 3) делает RET. Каждая рутина отрисовки делает RET и прыгает на следующую, пока не дойдет до DrawSprite@@done. В таблице они расставле─ ны так, чтобы правильно перепрыгивать по адресам строк. По возвращении в DrawSprite@@done вос─ станавливаем строку в таблице на начальное значение. Клиппинг небольшой есть по вертикали за счёт Skip8, Skip8End и DrawSprite@@done. Строки спрайта, которые попадают на верх─ ние координаты, не рисуются. А строки спрайта, которые попадают на нижние коор─ динаты, принудительно завершают рисование спрайта. Но если выставить слишком большую координату Y, то промахнемся мимо таблицы и будет креш. По горизонтали клиппинга нет. Для разных размеров спрайтов дополните─ льно есть небольшая табличка-дескриптор: SprOff16x16_8: dw 0 repeat 7,Y dw (16*4 + 2) + (16*6 + 2) * Y endrepeat db 16*2 ;height in pixels * 2 db 2,1,2 ;num of dirty vert tiles ;(default, align16, lessEq8) dw DrawSprite@@exitSP Последнее слово - адрес процедуры заве─ ршения отрисовки. Там ещё может лежать DrawSprite@@coloredXX для цветных спрайтов (пилюли, патроны, кристаллы, топливо), он дополнительно атрибуты пишет. DrawSprite@@exitSP - обычный выход, такой у большиства спрайтов. Num of dirty vert tiles - количество тайлов (тайлы - 16x16 ), которые пачкает спрайт (они будут перерисованы в следующем кадре, чтобы закрасить спрайт перед рисов─ кой нового кадра). Три числа вместо одного для оптимизации: для общего случая (не оп─ тимизированный), для случая,когда Y кратен 16, и когда (Y & 15) <= 8. Первые 8 слов - смещения от начала спрайта для каждого смещения по X. Спрайт выглядит так (смещения как раз указывают на соответствующий dw DrawRoutinesXX ): dw SprOff16x16_8 dw DrawRoutines16 db 0xe1,0x00,0x87,0x00 ; ...____..____... db 0xc1,0x0C,0x07,0x30 ; ..__##_.__##_... db 0xc0,0x10,0x07,0x40 ; .._#_____#___... db 0xc0,0x0F,0x1f,0x80 ; ..__#####__..... db 0x80,0x10,0x0f,0x40 ; .__#_____#__.... db 0x80,0x20,0x07,0xA0 ; ._#_____#_#__... db 0x80,0x2C,0x07,0x50 ; ._#_##___#_#_... db 0x80,0x26,0x03,0x10 ; ._#__##____#__.. db 0x80,0x06,0xc3,0x08 ; .____##_..__#_.. db 0xc0,0x16,0xe1,0x08 ; .._#_##_..._#__. db 0xc0,0x0C,0xe1,0x04 ; ..__##__...__#_. db 0xc1,0x10,0xf1,0x04 ; .._#___....._#_. db 0xc0,0x08,0xf1,0x04 ; ..__#___...._#_. db 0xe0,0x06,0x01,0x04 ; ...__##______#_. db 0xf0,0x01,0x01,0xF8 ; ....___######__. db 0xfc,0x00,0x03,0x00 ; ......________.. dw DrawRoutines24 db 0xf0,0x00,0xc3,0x00,0xff,0x00 db 0xe0,0x06,0x83,0x18,0xff,0x00 db 0xe0,0x08,0x03,0x20,0xff,0x00 db 0xe0,0x07,0x0f,0xC0,0xff,0x00 db 0xc0,0x08,0x07,0x20,0xff,0x00 db 0xc0,0x10,0x03,0x50,0xff,0x00 db 0xc0,0x16,0x03,0x28,0xff,0x00 db 0xc0,0x13,0x01,0x08,0xff,0x00 db 0xc0,0x03,0x61,0x04,0xff,0x00 db 0xe0,0x0B,0x70,0x04,0xff,0x00 db 0xe0,0x06,0x70,0x02,0xff,0x00 db 0xe0,0x08,0xf8,0x02,0xff,0x00 db 0xe0,0x04,0x78,0x02,0xff,0x00 db 0xf0,0x03,0x00,0x02,0xff,0x00 db 0xf8,0x00,0x00,0xFC,0xff,0x00 db 0xfe,0x00,0x01,0x00,0xff,0x00 ...ещё 6 блоков с DrawRoutines24... (все сдвиги по X) В спрайте - AND-маска и OR-маска. Количество одновременных спрайтов на экране сильно зависит от размера спрайтов и количества испачканных квадратов (напри─ мер,спрайт 8x8, стоящий между двумя тайла─ ми, будет есть больше ресурсов, чем выров─ ненный на границу знакоместа, так как надо стирать больше тайлов). В целом, получается 1 спрайт игрока + 3 больших врага ( 16x24, при некратных 8 координатах X - 24x24 ) и несколько спрай─ тов-выстрелов ( 8x8 ). На картах, где ещё были анимированные тайлы или другие спрай─ ты (лифт, шлюз и т. п.), ставили поменьше врагов. Тайлы рисуются отдельными рутинами.Весь экран - карта тайлов 16x10 (для текущей карты хранятся слова - прямые адреса тай─ лов в памяти, чтобы при отрисовке не рас─ считывать адреса; сами данные карт хранят один байт для экономии места). Дополните─ льно есть битовая маска проходимости и две маски грязных тайлов (по одной на обычный и теневой экранный буфер). Данные тайлов занимают 36 байт ( 32 ба─ йта пиксели и 4 байта атрибуты) и идут в чередующемся порядке: 1 -> 2 | 4 <- 3 v Такой порядок позволяет сэкономить нем─ ножко тактов на обновлении адресов в реги─ страх. Alone> Как я понимаю, спрайты должны лежать в нижней памяти,т.к.вывод попеременно ведёт─ ся в нижний и верхний экран? Николай Запольнов> Да, в нижней памяти (банки 2 и 5 ),либо в банке 7 (где и находится теневой экран). Очень не хватает, конечно, возможности ма─ пить память на ROM. Alone> Пилюли,патроны и т.п. сделаны не тайла─ ми,чтобы могли накладываться на любой фон? Николай Запольнов> Чтобы они могли накладываться на фон и чтобы было легко реализовать возможность их подбирать.Кроме того,изначально мы сде─ лали их обычными спрайтами, а раскрасить решили потом (они были плохо заметны), по─ этому добавить возможность рисовать атри─ буты вместе со спрайтом выглядело лучшим решением,чем переписывать всё на использо─ вание тайлов. Alone> А как сделана анимация тайлов? Для те─ кущей локации создаётся список изменяемых тайлов,а потом всё вручную? Как это описы─ вается в редакторе? Николай Запольнов> В редакторе это никак не описывается, анимации заданы для конкретных тайлов в lua-скрипте (как и задержки для анимаций тайлов). Для каждой карты формируется список анимированных тайлов: db 3 ;numAnimatables ;animatable 1 db 1,0 ;delay,counter db 2,0 ;count,index dw MapAnim_358e04958311f5a6b5f9 ;tile list dw Map_05_08@@anim1 - Map_05_08 ;offset ;into map data db 12,4,0 ;mapY,mask1,mask2 dw 420 ;target screen address ;animatable 2 db 4,0 ;delay,counter db 8,0 ;count,index dw MapAnim_6aee9a687735e6fe7e67 ;tile list dw Map_05_08@@anim2 - Map_05_08 ;offset ;into map data db 12,16,0 ;mapY,mask1,mask2 dw 424 ;target screen address ;animatable 3 db 4,0 ;delay,counter db 8,0 ;count,index dw MapAnim_6aee9a687735e6fe7e67 ;tile list dw Map_05_08@@anim3 - Map_05_08 ;offset ;into map data db 12,32,0 ;mapY,mask1,mask2 dw 426 ;target screen address ... MapAnim_6aee9a687735e6fe7e67: dw MapTile176 * 36 + TILES_BASE dw MapTile177 * 36 + TILES_BASE dw MapTile178 * 36 + TILES_BASE dw MapTile179 * 36 + TILES_BASE ... И вот такой рутиной обновляется: ; Input: None ; Output: None ; Preserves: A', BC', DE', HL', IXH ; Trashes: A, BC, DE, HL, IXL, IY UpdateMapAnimatedTiles: ld a,(NumMapAnimatables) or a ret z pushAllowWrite MapAnimatables, MAX_ANIMATABLES * 13 pushAllowWrite MapTiles, 16*10*2 pushAllowWrite MapDirty1, MapDirtyEnd - MapDirty1 ld hl,MapAnimatables ld ixl,a @@loop: ld c,(hl) ;delay inc hl ld a,(hl) ;timer inc a ;increase timer cp c ;reached delay? jr z,@@1 ld (hl),a ;store new timer value inc hl inc hl ld a,(hl) ;index jp @@3 @@1: xor a ld (hl),a ;reset timer inc hl ld c,(hl) ;count inc hl ld a,(hl) ;index inc a ;next frame inc a cp c ;reached limit? jr nz,@@2 xor a @@2: ld (hl),a ;store new index @@3: inc hl ld e,(hl) inc hl ld d,(hl) ;DE = list of tiles inc hl ex de,hl ;save HL ld b,0 ;HL = list of tiles ld c,a add hl,bc ;add index to HL ld a,(hl) ;HL = new tile address inc hl ld h,(hl) ld l,a ex de,hl ;restore HL ld iy,32 ;DE = new tile address add iy,de ;IY = attribute address ;load target address into BC ld a,(hl) ;BC=offset into map data inc hl add a,0xff&MapData ld c,a ld a,(hl) adc a,0xff&(MapData>>8) ld b,a inc hl ;write new tile to target address ld a,e ld (bc),a inc bc ld a,d ld (bc),a ;mark tile as dirty ld b,0 ld c,(hl) ;BC=offset into dirtymap inc hl ;update dirty map ex de,hl ;save HL into DE ld hl,(MapDirtyBack) add hl,bc ;HL = corresponding line in MapDirty ld a,(de) ;first mask bit or (hl) ;apply ld (hl),a ;store into dirty map inc hl inc de ld a,(de) ;second mask bit or (hl) ;apply ld (hl),a ;store into dirty map inc de ex de,hl ;restore HL ;update attributes ld e,(hl) inc hl ld d,(hl) ;DE = target screen addr inc hl ld b,iyh ;BC = tile attributes ld c,iyl call DrawMapTileAttributes@@1 ;next iteration dec ixl jr nz,@@loop popAllowWrite MapDirty1, MapDirtyEnd - MapDirty1 popAllowWrite MapTiles, 16*10*2 popAllowWrite MapAnimatables, MAX_ANIMATABLES * 13 ret Суть процедуры состоит в том, чтобы: а) прописать новый адрес тайла в распа─ кованные данные текущей карты в памяти; б) поставить грязный флаг для тайла,что─ бы при следующем обновлении экрана его пе─ рерисовала та же процедура,которая стирает спрайты; в) заменить атрибуты на экране (процеду─ ра стирания тайлов атрибуты не трогает, чтобы сэкономить время,так как большинство спрайтов красятся в цвет фона; коду, кото─ рому нужно перекрасить атрибуты,приходится вызывать DrawMapTileAttributes отдельно). Alone> У тебя свой редактор карт? Как там де─ лается привязка событий к точкам карты? Есть какая-то система скриптования? Николай Запольнов> У меня всё своё: ассемблер, редактор спрайтов,редактор карт,полная IDE.Включает в себя еще допиленный напильником эмулятор Fuse (и интегрирует с ним отладчик), ком─ пилятор SDCC, bas2tap, tap2wav и другие утилиты.Есть редактор графики и тайлсетов. Инструменты для импорта и экспорта PNG и SCR. На самом деле там куча багов,граблей, тормозов и недоделок. Я потихонечку, когда есть минутка, работаю над улучшенной вер─ сией. От ассемблера мне нужно было много ве─ щей,которых не хватало в имеющихся инстру─ ментах: 1) генерация отладочной информации для отладчика (строка в файле, адреса перемен─ ных - хотя их я так и не доделал в отлад─ чике...). 2) быстрый и удобный расчет T-state для инструкций (у меня они отображаются в IDE справа от номера строки). 3) расширенные макро-инструменты (напри─ мер,у меня есть мета-инструкции для прове─ рки выхода за границы памяти, и мой эмуля─ тор проверяет операции чтения/записи; что- то вроде valgrind для ПК - кучу багов пой─ мал с помощью них). Alone> Это макросы pushAllowWrite MapDirty1, MapDirtyEnd - MapDirty1? Там адрес и длина разрешённых адресов записи? Николай Запольнов> Да, первый аргумент - адрес, второй - длина. Макросы push кладут на стек разрешение записи в указанную область, pop убирают их со стека (в pop нужно передавать такие же значения, как и в push - эмулятор проверя─ ет, что порядок push/pop не нарушен). Макросы прописываются в дебаг-информа─ цию и привязываются к адресу следующей за ними инструкции. Когда эмулятор выполняет инструкцию, он проверяет, есть ли там эти макросы,и так же их выполняет. А при обра─ ботке операций чтения/записи в память - проверяет текущий стек на предмет,разреше─ на ли запись. Если не разрешена,останавли─ вает выполнение и кидает в отладчик, так же,как и брейкпоинт. Можно посмотреть код, регистры и т. п. и при желании продолжить выполнение. 4) расширенный инструментарий работы с секциями. Я могу указывать адреса, в какие файлы писать секции, сжатие посекционно и т. п. Плюс на выходе генерируется полная карта памяти, что мне очень помогло. ... section bank2_langmenu [file "BANK2"] section z_intro_strings_ru [file "BANK2", compress=lzsa2] section z_intro_strings_en [file "BANK2", compress=lzsa2] section bank3 [file "BANK3", base 0xc000] section bank4 [file "BANK4", base 0xc000] section bank4_data_alien1 [file "BANK4", compress=lzsa2] ... Карта памяти выглядит так: Data_tiles_22_A 0xDE16/56854..0xDE33/56833 36/30 byte(s) ...ещё 18 строк в таком же стиле... BANK1:imaginary> Bank1_music_buffer_bss 0xE084/57476..0xFDE6/64998 7523 byte(s) Bank1_engine_bss 0xFDE7/64999..0xFFFF/65535 537 byte(s) BANK2> ... Alone> 36/30 byte(s) - это размер выделенного места и фактически занятое место? Николай Запольнов> Да, первое число - оригинальный размер, второе - после сжатия. На самом деле 36-байтные куски - плохой пример для сжатия :) Это единичные тайлы, которые я вкрячивал куда попало уже в пос─ ледний момент, чтобы разместить побольше тайлов. Они раскиданы по разным местам в памяти, и некоторые плохо жмутся (можно найти, например, 36/38, т.е. сжатая версия больше, чем оригинал); но добавлять для каждого тайла флаг, сжат он или нет, и делать две ветки кода получается длиннее, чем потерять несколько байт на несжавшихся тайлах. Для bas2tap я добавил пару псевдоинст─ рукций, чтобы, например встраивать ассемб─ лерный код сразу в инструкцию REM. Вот так выглядит исходник boot.bas: 0 REM @{loader} 10 LET A=VAL"23635": RANDOMIZE USR(PEEK(A+ PI/PI)*VAL"256"+PEEK A+VAL"5") В сборщик интегрирован движок Lua, и вся сборка написана на Lua. На самом деле там ужаснейшие скрипты по несколько тысяч строк,в которых уже сам черт ногу сломит:) Привязка событий к точкам карты дела─ ется через строковые значения,которые пар─ сятся lua-скриптом. Каждому тайлу на карте можно сопоставить строку (или несколько), нажав правой кнопкой мыши. В редакторе карты они обозначены жёлтым прямоугольни─ ком с надписью поверх игрового экрана. Alone> А что получается в результате выполне─ ния скрипта сборки на Lua? Николай Запольнов> Скрипты генерируют даже не бинарники, а исходники на ассемблере.Помогает в отладке скриптов. Выше показан пример кода спрайта - он сгенерён lua-скриптом;список анимированных тайлов - тоже. Вот такой массив, например, генерируется с информацией о врагах: section bank0_data_enemies MapEnemyInfo: @@1: db 3 | (Offset_SpaceFlyingAlienAI << 2) ;EnemyInfo_spriteCntAndAI db 64,104 ;EnemyInfo_origX, _origY db 0,0,24 ;EnemyInfo_x, _y, _h db 4 | (4<<3) | 0, 0, 0 ;_healthAndFlags, ;_stateAndFlags, _time @@2: db 3 | (Offset_SpaceFlyingAlienAI << 2) ;EnemyInfo_spriteCntAndAI db 176,104 ;EnemyInfo_origX, _origY db 0,0,24 ;EnemyInfo_x, _y, _h db 4 | (4<<3) | 0, 0, 0 ;_healthAndFlags, ;_stateAndFlags, _time ... В общем, всякие данные из спрайтов,карт и прочего в удобном для движка виде. Ещё я делал себе вспомогательный вывод, например, дампил информацию,в каких картах присутствуют те или иные тайлы: ... gfx/tiles_NEW/12_06_lab_wall_center.gfx 09_05 09_06 10_07 gfx/tiles_NEW/00_06_starport_mid2.gfx 04_03 04_04 04_05 gfx/tiles_NEW/01_00_human_bottle.gfx 10_05 10_07 Zintr1 gfx/tiles_NEW/01_00_human_bottle.gfx 10_05 10_07 Zintr1 ... Или вот, например, генерировал из lua-скрипта целую карту в PNG, чтобы можно было оценить, как они стыкуются между со─ бой, и искать ошибки. Alone> Как описываются анимации спрайтов? В чём они редактируются? Я хотел применить Pixelorama, но она не поддерживает палит─ ровый режим и одновременное редактирование нескольких анимаций с использованием общих слоёв. И я не понял, как оттуда выгрузить задержки с точностью 1/50 с (GIF такие не поддерживает). Николай Запольнов> Редактор анимаций, хех :)) Хотелось бы. Пока что кадры и задержки анимаций описы─ ваются в коде... _AlienWalkSprites: dw _alienGunWalkRight1 dw _alienGunWalkRight2 dw _alienGunWalkRight3 dw _alienGunWalkRight4 dw _alienGunWalkLeft1 dw _alienGunWalkLeft2 dw _alienGunWalkLeft3 dw _alienGunWalkLeft4 ... @@doWalk: ld a,(ix+EnemyInfo_time) rrca rrca and 0x3f ld e,a ... Для анимированных тайлов - захардкожены в maps.lua: If animName==gfx/tiles/acid/acid_anim.gfx or animName==gfx/tiles/acid/ acid_top_anim.gfx then delay = 5 deadlyArea[y * mapW + x] = true ... elseif animName==gfx/level_elements/ terminal_bottom_anim.gfx then delay = 10 ... delay попадает в блок данных карты об анимированных тайлах (выше был пример спи─ ска анимированных тайлов). Alone> Что значит DeadlyArea[y*MapW+x] = true? Николай Запольнов> DeadlyArea - это массив "смертельных" тайлов. Туда пишется true для шипов и кис─ лоты и false для обычных тайлов. Они потом сохраняются скриптом в отдельный блок, и движок проверяет пересечения персонажа с этими тайлами. Если игрок попадает на тайл DeadlyArea, он получает ранение. Alone> Будут ли выложены исходники игры? По поводу их вида - ничего страшного :) Ты, наверно, уже видел,как выглядят исходники, которые спектрумисты обычно выкладывают :) Николай Запольнов> Видел.Но тут еще прибавляется собствен─ ный ассемблер, скрипты на Lua,генерирующие спрайты, и прочие удовольствия. Шрифты генерируются одним скриптом и в одном формате, тайлы - другим и в другом (плюс там адская упаковка и перетасовка в памяти, делалось под конец и впопыхах), спрайты - третьим скриптом, тоже есть нес─ колько вариантов - ч/б и цветные. Спрайты распаковываются и подогнаны так, чтобы занимать одинаковые адреса (на─ пример, код всегда адресует спрайты игрока по одним адресам, а на их места распаковы─ ваются спрайты с пушкой или в скафандре, когда игрок подбирает соответствующие пре─ дметы; такая же история со спрайтами обыч─ ного элиена и элиена с пистолетом, напри─ мер). К тому же, в памяти сейчас всё очень плотно, я в последние дни ещё навёл там хаоса...