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,генерирующие
спрайты, и прочие удовольствия.
Шрифты генерируются одним скриптом и в
одном формате, тайлы - другим и в другом
(плюс там адская упаковка и перетасовка в
памяти, делалось под конец и впопыхах),
спрайты - третьим скриптом, тоже есть нес─
колько вариантов - ч/б и цветные.
Спрайты распаковываются и подогнаны
так, чтобы занимать одинаковые адреса (на─
пример, код всегда адресует спрайты игрока
по одним адресам, а на их места распаковы─
ваются спрайты с пушкой или в скафандре,
когда игрок подбирает соответствующие пре─
дметы; такая же история со спрайтами обыч─
ного элиена и элиена с пистолетом, напри─
мер).
К тому же, в памяти сейчас всё очень
плотно, я в последние дни ещё навёл там
хаоса...
Other articles: