Всем салют!
Очень приятно, что многим зашёл недавний разбор моего интро. Я рад, что у меня, наконец, дошли до этого руки. Лёд тронулся, господа присяжные заседатели. На сегодня мы имеем:
-
64b nano game: snake64 (вы находитель здесь)
На сей раз предлагаю вашему вниманию 64-байтовую игру «змейка», того же автора. Да, друзья, это самая компактная по размеру бинарного кода змейка из существующих (у меня даже есть 45-байтовая версия, но не такая симпатичная). И это именно та самая любимая многими вечно голодная змейка со старых мобильников, которая бегает по экрану и постоянно что‑то жуёт, увеличиваясь в длине.
Если вам не терпится поиграть прямо здесь и сейчас, только сегодня и только для вас я подготовил онлайн-версию, играйте и радуйтесь! Важно: для игры нужно использовать стрелки на цифровой клавиатуре (иначе может произойти непоправимое: вы внезапно проиграете 😱).
Мрак! Жуть! Блеск!
Именно так сказала бы Эллочка‑людоедка из известного произведения Ильфа и Петрова, увидев код. А что вы думаете об этом? (снова fasm 1)
; [ snake64 ] 64-byte game
; MAIN VERSION (pure code without if's)
; (c) 2020 Jin X
; Keys: direction control - numpad arrows (4, 6, 8, 2); exit - numpad 5 / enter.
; Snake can run through left and right screen bounds but dies when trying to run beyond top or bottom of screen.
; IMPORTANT:
; You should run the game via command "DOSBox.exe snake64.com" (but not from DOSBox terminal) !!!
; If you want to run it from FreeDOS / MS-DOS then you should do it from BAT-file with lines:
; @pause
; @snake64.com
; ...and press "down" key on pause. The game requires value 0 ($80) or $50 ($D0) in port $60 on start!
;-- USER SETTINGS ------------------------------------------------------------------------------------------------------
body_color = $1F ; highest bit must be reset & parity (number of set bits) must be even after xoring with 7
apple_color = $D9 ; highest bit must be set
init_len = 5 ; initial snake length
;-- MAIN CODE ----------------------------------------------------------------------------------------------------------
org $100
; Initializing (assume: ax = 0, bx = 0, ch = 0, si+bp > $140 ($100+$91x), ss = cs)
int $10 ; 40x25 video mode
push $B800 - (65536-40*25*2)/16 ; snake will die when run beyond screen top or bottom
pop ds ; video memory
lahf ; ah = 2
mov dx,(19-40)*2+1 - 40*25*2 ; initial coordinate (should be odd)
int $10 ; hide cursor
mov word [bp+si],dx ; initial coordinate
mov cl,init_len - 2 ; initial snake length minus 2
; Main loop (cx = snake length, [bp+si] = snake coordinate array (from head to tail))
.inc:
inc cx ; increase snake length
inc cx
.move:
hlt ; delay
hlt ; delay
; Apple appearance
imul bx,45 ; random number generator
inc bx
xor byte [bx],apple_color ; draw new apple
; Keypress processings
pusha ; cx & si
in al,$60 ; read scan code
aaa ; [need af=0] left=1 right=3 up=8 down=0
cbw ; ah = 0
dec ax
dec ax ; left=-1 right=1 up=6 down=-2
jc @F ; left or right (checking cf after aaa)
sub al,2 ; up=4, down=-4
imul ax,-10 ; up=-40, down=40
@@: shl ax,1 ; left=-2 right=2 up=-80 down=-80
out $61,al ; sound
add ax,[bp+si]
; Movement and snake redraw
xchg di,ax ; new head coordinate
xor byte [di],body_color ; draw snake (and set flags for jcc below)
@@: xchg [bp+si],di ; swap current value with next
lodsw ; next value (si += 2)
loop @B
mov byte [di],dl ; clear tail (dl = 7)
popa
; Checks and jumps
js .inc ; snake ate an apple
jpe .move ; moved to free space
ret ; die / exit
Как вы видите, этот набор малопонятных Эллочке символов компилируется в программу в формате COM под DOS. Действие разворачивается в текстовом режиме 40×25 цветных знакомест. Для его установки необходимо вызвать int 10h
с AX = 0 (о начальных значениях регистров читайте статью про radar, ссылка в начале статьи, в комментариях исходника интересующие нас значения также указаны).
Видеопамять отображается на сегмент $B800, но мы занесём в регистр DS значение $B800 – (65 536–40*25*2)/16 = $A87D (16-ричные числа в этой статье я буду предварять знаком $ в стиле языка Pascal, fasm его тоже поддерживает; ИМХО, это гораздо удобнее и читабельнее, чем 0x или 0+h). Почему такое странное число? А так прикольнее! 🙂 … Ну хорошо, расскажу, но с вас шоколадка. Каждый знак на экране кодируется двумя байтами: первый байт пары — символ, второй — цвет (младший полубайт — цвет текста, старший — цвет фона, включающий признак мерцания в старшем бите). Таким образом, весь экран помещается в 40*25*2 = 2000 байтах. Если мы вычтем это значение из 65 536, получим смещение буфера перед концом 64 КБ сегмента, т. е. от 63 536 до 65 535 (включительно) у нас ровно 2000 байтов. Разделив это число на 16, получим разницу сегментной части адреса (вспоминаем, что адреса в реальном режиме состоят из сегмента и смещения; линейный адрес, он же физический, получается по формуле: сегмент*16 + смещение). Итак, $A87D*16 + 63 536 = $B8000 — это линейный адрес начала видеопамяти. Т. е. теперь нам нужно писать по смещениям от 63 536 до 65 535 (вкл), иначе мы ничего не увидим. Но на кой это всё? Лёгкие пути — для слабаков, настоящий сайзкодер их не ищет (разгадка в конце статьи, придётся читать весь текст) :))
Однобайтовой инструкцией lahf
записываем в регистр AH значение 2 (потому что все основные арифметические флаги на старте сброшены). В регистр DX заносим магическое значение (19-40)*2+1 – 40*25*2 = $F807 — это смещение адреса начальной позиции змейки (оно чуть меньше, чем 63 536, змейка исходно находится немного за пределами экрана, но это не страшно). Значение нечётное, так как мы будем рисовать цветом фона, а не символами. Далее нам нужно спрятать курсор, чтобы он не раздражал своим миганием. Для этого вызываем int 10h
(функция AH = 2 устанавливает позицию курсора на видеостранице BH = 0 по координатам X, Y = DL, DH = 7, 248 ($F8), что далеко за пределами экрана).
Заносим это значение в память по адресу SS:BP+SI — здесь у нас будет массив координат (вернее, смещений) туловища нашего зверя (конкретно по этому адресу находятся координаты головы). Вероятно, вы заметили, что в инструкции mov word [bp+si],dx
нет префикса SS. Он здесь и не нужен, поскольку при адресации через регистр BP (даже вместе с SI или DI) по умолчанию используется сегмент SS, а не DS (всё-таки, DS у нас указывает на видеопамять; а вот SS = CS). Один регистр BP мы использовать не можем, так как он требует дополнительного байта для хранения смещения (есть у него такая особенность), а вот при использовании пары BP+SI использование смещения опционально. К тому же, SI нам тоже тут нужен (наберитесь терпения). Нам также важно, чтобы эта сумма была не меньше, чем $140, т. е. указывала за пределы нашего кода (чтобы не затереть его; по факту же эта сумма превышает даже $A00, потому что… см. начальные значения регистров в статье про radar) :). Далее записываем в CX (вернее, нам достаточно в CL) исходную длину змейки, уменьшенную на 2: init_len – 2 = 3. На этом инициализация закончилась. Но не стоит нервничать.
Спокойно, Михельсон!
Раз мы уменьшили змейку при инициализации на 2, значит нам теперь нужно её увеличить, потратив 2 байта inc cx
+ inc cx
(у нас же целых 64 байта в запасе, нам не жалко). А после сделать задержку, потратив ещё 2 байта hlt
+ hlt
(это, конечно, расточительство, но деваться некуда). Напомню, что hlt
ждёт любого аппаратного прерывания (т. е. прерывания от таймера, в данном случае это около 110 мсек).
Чтобы изголодавшаяся змейка не вылезла из экрана, нужно подбросить ей немного яблок (ключевое слово — немного). Для этого запустим генератор псевдослучайных чисел: imul bx,45
+ inc bx
, который даст нам результат в регистре BX. Да, это тот самый линейный конгруэнтный метод Лемера. В качестве множителя сойдёт практически любое значение (даже отрицательное), кратное 4 и увеличенное на 1, в т. ч. 45. После этого выполним операцию xor byte [bx],apple_color
, которая нарисует на экране яблоко (красивое квадратное розовое на белом яблоко: apple_color = $D9
; $D9 xor 7 = $DE, где 7 — серый цвет на чёрном фоне, которым изначально заполнен весь экран). Почему xor
, а не or
? Потому что если мы вдруг попадём в змейку, то она разозлится и перекрасит после себя это яблоко в красный цвет (body_color or apple_color xor body_color = $1F or $D9 xor $1F = $C0
). А почему $D9, а не $D0 или $50, ведь цвет символа и признак мерцания нас вообще не интересуют? Потому что наша случайная координата может оказаться чётным числом, и тогда мы попадём в байт, соответствующий не цвету, а символу. Изначально все символы на экране — пробелы, имеющие код 32 ($20). А значит $50 xor $20 = $70 — это буква «p», $D0 xor $20 = $F0 — это символ «≡» (или «Ё»), зачем они нам? На самом деле точки, которые вы видите на экране — это не баг ($D9 xor $20 = $F9 — символ точки по центру). Можете назвать это дождиком или снежинками. Его можно заменить на более мелкую точку (если использовать $DA) или убрать вовсе ($DF xor $20 = $FF — запасной пробел). Публикуя эту работу, я сомневался, стоит ли оставлять эти точки, но решил, что так красивее (к тому же, мне кажется, что змеи любят дождь… по крайней мере, дождевые черви точно).
Вы думаете, что яблоко будет рисоваться на каждом шаге змейки? Отнюдь! Давайте посчитаем, какова вероятность появления на экране фрукта. Наш ГПСЧ выдаёт одно из 65 536 значений, но на видимую область экрана может попасть только 2000, причём половина из этих значений приходится на рисование дождика, а не яблок. Итого имеем 1 / 65,536 ≈ 1.5 %. Вполне нормально, ничего для ограничения кормёжки больше делать не нужно.
Крепитесь! Заграница нам поможет!
Пора реагировать на присутствие игрока. На всякий случай сохраним регистры CX и SI. Самый экономный способ сделать это — использовать инструкцию pusha
. Напомню, что в CX у нас хранится длина нашего червя, а по адресу SS:BP+SI — таблица координат (смещений в сегменте DS) его туловища. Читаем скан-код клавиши (in al,$60
) и выполняем магическую однобайтовую инструкцию aaa
(что она делает — RTFM). Эта инструкция действительно магическая, так же как и aas, daa, das, которые производят интересные трансформации с регистром AL (и иногда с AH). Эти инструкции иногда спасают, когда нужно одним байтом причесать значение регистра AL (или добавить разнообразия в цвета, или что-нибудь ещё). Можно не вникать в то, как они работают, а просто перебирать их все подряд и смотреть на результат через отладчик, надеясь на чудо. Бывает, что это срабатывает :). Так вот, давайте посмотрим что происходит (если вы не против). Нас интересуют клавиши влево, вправо, вверх и вниз (лучше на цифровой клавиатуре, так как обычные стрелки предварительно посылают ещё и код $E0, а он будет вносить только лишнюю суету в движения нашей бедной змейки). Заодно взглянем на Esc, Enter и центральную клавишу с цифрой 5.
|
|
Значение AX и флага CF после |
Значение AX после вычитания 2; повторного вычитания 2; умножения на -10; удвоения |
||||
нажатий не было |
0 |
AX=0 |
CF=0 |
AX=-2 |
-4 |
40 |
80 |
Влево (4) ← |
75 ($4B) |
AX=1 |
CF=1 |
AX=-1 |
|
|
-2 |
Вправо (6) → |
77 ($4D) |
AX=3 |
CF=1 |
AX=1 |
|
|
2 |
Вверх (8) ↑ |
72 ($48) |
AX=8 |
CF=0 |
AX=6 |
4 |
-40 |
-80 |
Вниз (2) ↓ |
80 ($50) |
AX=0 |
CF=0 |
AX=-2 |
-4 |
40 |
80 |
Центр (5) |
76 ($4C) |
AX=2 |
CF=1 |
AX=0 |
|
|
0 |
Esc |
1 |
AX=1 |
CF=0 |
AX=-1 |
-3 |
30 |
60 |
Enter |
28 ($1C) |
AX=2 |
CF=1 |
AX=0 |
|
|
0 |
Ситуация в первой строке крайне маловероятна на реальной машине, так как перед запуском мы по любому какую‑нибудь клавишу да нажмём (хотя в теории, конечно, мы можем запустить игру мышкой). Так что я рекомендую (как написано в комментах) создать BAT‑файл, начинающийся командой pause
, и при запуске нажать клавишу «вниз», поскольку Enter вызовет завершение игры (см. ниже). А вот в DOSBox, если мы ничего не нажимали, мы будем получать 0, и это будет работать аналогично клавише «вниз». При отпускании клавиши порт 60h выдаёт то же значение, но с установленным старшим битом, однако инструкция aaa
всё равно сбросит старшие 4 бита, так что всё будет работать так же, как и при удержании клавиши.
В данном случае для aaa
важно, чтобы флаг AF был предварительно сброшен (а он будет сброшен после xor
), иначе мы получим не то, что хотим. Далее очищаем регистр AH (однобайтовой инструкцией cbw
). Уменьшаем AX на 2. И если CF сброшен, вычитаем из AL (AX) ещё 2 и умножаем AX на -10. После этого (уже при любом CF) удваиваем AX :)))
Произошло чудо: всего за 13 байт кода регистр AX принял значение, равное смещению позиции на экране (-2 / 2 — влево / вправо на 1 символ, -80 / 80 — вверх / вниз на 1 символ). Центральная клавиша и Enter оставят змейку на месте (в этом случае произойдёт крах) — это клавиши выхода из игры. А вот с Esc нам не повезло: змейка будет вести себя неадекватно и извиваться, как червь, так что для выхода эта клавиша не годится.
Остался только один вопрос: при чём тут заграница и как она нам поможет? Дело в том, что этот трюк я подсмотрел в Hugi Compo (The Nibbles) у участника-победителя с именем Altair из группы ODDS (спасибо тебе, добрый труженик демосцены… хотя вряд ли ты это прочтёшь). В демосцене мы иногда подглядываем трюки друг у друга и используем их :). В этом нет ничего зазорного (к примеру, есть знаменитый «Řrřola trick» преобразования индекса точки на экране в координаты X и Y, которым многие пользуются).
Далее при нажатии на клавишу выводится щелчок (out $61,al
), и к значению регистра AX добавляется координата головы (add ax,[bp+si]
).
И тут змейку понесло…
Обновлённую координату головы змейки мы переносим в регистр DI и отрисовываем эту самую голову на экране (xor byte [di],body_color
). При этом у нас происходит следующая ситуация с флагами:
-
Если мы попали на пустое поле: [DI] = 7 xor $1F = $18: SF = 0, PF = 1;
-
Если мы попали в яблочко: [DI] = $DE xor $1F = $C1: SF = 1, PF = 0 (но в этом случае последний нам неважен). Операция
xor
, кстати, при съедании яблока создаёт эффект перемещения яблока по телу; -
Если мы решили заняться самоедством: [DI] = $18 xor $1F = 7: SF = 0, PF = 0.
Сфотографируйте это, чуть позже оно нам понадобится.
Начинаем цикл: меняем местами текущую координату-смещение и значение по адресу SS:BP+SI. На первой итерации мы записываем новую координату головы, получая прежнее значение. После этого увеличиваем SI на 2 (lodsw
, AX нам не нужен — это просто побочный эффект). И повторяем процесс CX раз (соответственно длине змейки). Таким образом, массив координат смещается на 1 элемент к хвосту. Красиво? То-то! :))
Осталось удалить хвост (mov [di],dl
; вы же помните, что в DL у нас лежит 7? Если нет, крутите на начало статьи). И восстановить регистры (popa
; нам нужно вернуть на место CX и SI).
Браво, гусар!
У нас осталось всего 3 инструкции и 5 байт кода:
-
js .inc
— прыгаем, если мы съели яблоко (SF = 1); -
jp .move
— прыгаем, если мы не съели яблоко, но ещё живы (PF = 1); -
ret
— увы и ах!
На этом можно было бы закончить, но если вы ещё не впали в транс от написанного, у вас мог бы возникнуть вопрос: «А как же стулья стены? Неужели в этой игре нет стен? Это же печально!»… Без паники! Они есть. Правда, не везде. Змейка может проходит сквозь стены слева и справа (перемещаясь на строку выше или ниже — вселенная искривлена, знаете ли). А вот с перемещением вверх и вниз такой фокус не удастся. Почему? Как говорят некоторые менеджеры по продажам: «Давайте подумаем вместе». Мы не зря записали в DS значение $A87D вместо $B800 (и я обещал раскрыть тайну смысла). После инициализации видеорежима видеопамять отображается на сегмент $A000 (64 КБ), $B000 (32 КБ) или $B800 (32 КБ, наш случай) в зависимости от видеорежима. Другие сегменты из этого списка всё время обнулены (в DOSBox не всё время, но при старте — да). Таким образом, до $B800 у нас находятся нули (т. е. в сегменте $A87D по смещениям от 0 до 63 535 (вкл) нули, поскольку видимая область, напомню, лежит в диапазоне смещений от 63 536 до 65 535 (вкл), а ниже находятся области сегментов видеопамяти $A000 и $B000). При пересечении нижней границы у нас происходит перенос, и смещение становится меньше, чем 63 536 (например, было 65 495, добавилось 80, стало 39). При пересечении верхней границы — тоже. В обоих случаях, как вы понимаете, мы попадаем в область нулей. Если бы мы инициализировали DS = $B800, видимая область была бы по смещениям от 0 до 1999 (вкл), но и выше тоже не было бы нулей (были бы серые пробелы — слова $2007), так как сегмент видеопамяти имеет размер 32 КБ и состоит из нескольких страниц, которые можно переключать. А теперь давайте посмотрим, что будет с цветом, если наша змейка шагнёт в эту бездну: 0 xor $1F = $1F, флаг SF = 0, PF = 0 (а это верная гибель). Что и требовалось доказать.