Написал «Змейку» для Искры 1080 Тарту

1 октября 2024

Недавно мы познакомились с ПЭВМ Искра 1080 Тарту. Для данного компьютера написано мало программ. Достоверно известно о существовании менее тридцати, включая порты с других компьютеров. Я попытался слегка улучшить ситуацию, написав еще одну программу. Ею стала игра «Змейка».

Подготовка окружения для разработки

Программы для Искры можно писать и на самой Искре, но по нынешним меркам это не очень-то удобно. Было решено писать код на современном железе, а Искру использовать для тестирования.

Первым делом предстояло выбрать ассемблер 8080. Многие рекомендуют zasm. Но мне показалось, что это какой-то слишком сложный ассемблер. Ассемблер всего-то переводит мнемоники в машинный код и заменяет именованные метки на адреса в памяти. Притом, первое несложно делается и вручную, по табличкам. А вот пересчитывать все метки при добавлении новых инструкций — занятие действительно не самое захватывающее. Его и хотелось бы автоматизировать в первую очередь.

Звучит как что-то, что решается в пару сотен строк на Python. Написать свой ассемблер 8080 на Python безусловно является привлекательной идеей для проекта, но наверняка это уже решенная кем-то задача. Я поискал, и нашел suite8080. Пакет включает в себя ассемблер asm80 и дизассемблер dis80.

Оказалось, что asm80 умеет все, что мне нужно, за исключением анонимных меток в стиле MASM. Это когда можно давать меткам имена @@ и ссылаться на следующую такую метку при помощи @F, а на предыдущую — при помощи @B. Анонимные метки позволяют не придумывать каждой метке свое уникальное имя. Я отправил pull request. Однако автор suite8080 сообщил, что больше не хочет заниматья проектом, и заархивировал репозиторий. Тогда был создан форк.

Чтобы при компиляции программы получался LVT файл, используем такой прием:

    org 234                ; 234 = 0100h - 16 - 3*2

    ; Заголовок LVT файла общим размером 16+3*2 байт
    ; Этих данных реально не будет в программе
    ; Важно! Имя программы ограничено шестью символами
    db 'LVOV/2.0/', 0D0h, 'ZMEJKA'
    dw entry
    dw code_end
    dw entry

    ; ...

entry:                    ; точка входа получает адрес 0100h

    ; ...

code_end:

Прием я подсмотрел у Alemorf’а. Искра 1080 Тарту использует такой же формат хранения данных не ленте, что и Львов ПК-01. Значит, можно воспользоваться утилитой lvt2wav для получения WAV-файла из LVT (Lvov Tape). WAV-файл воспроизводится на компьютере и загружается Искрой, как если бы это были данные с ленты.

Для написания кода сойдет любой текстовый редактор. Я лично воспользовался Sublime Text. Практика показала, что подсветка синтаксиса для ассемблера 8080 не нужна. Но если очень хочется, то ее несложно добавить, воспользовавшись наработками из репозитория PDPy11. Вы можете помнить последний по заметке
Программируем на ассемблере под БК-0010-01.

Какими-либо эмуляторами-отладчиками я не пользовался. По моему скромному опыту, они не требуются.

Дополнение: Эмулятор для Искры 1080 Тарту существует. Он имеет некоторые шероховатости. В частности, программы в нем работают заметно быстрее, чем на реальном железе. Но в общем и целом, данный эмулятор весьма неплох.

Написание игры

Разрешение экрана в Искре составляет 384x256 пикселей. С пикселями можно работать индивидуально, но это неудобно, да и не особо эффективно. Было решено поделить пространство экрана на квадраты 16x16 пикселей. Получается 24 клетки по горизонтали и 16 клеток по вертикали, где в правом нижнем углу находится клетка (0,0), а в левом верхнем — (23,15). Игровая логика строится в координатах этих клеток.

Следующий код отвечает за рисование картинки (спрайта) в заданной клетке:

; Нарисовать картинку 16x16 по координатам (X, Y)
;   (0, 0) - правый нижний угол
;   (23, 15) - левый верхний угол
;
; Аргументы:
;   B - координата X
;   C - координата Y
;   HL - адрес картинки
;
; Портит регистр A
draw_image:
    push d                      ; запомнить DE для удобства вызывающих

    push b                      ; запомнить BC четыре раза
    push b
    push b
    push b

    mvi d, 090h                 ; BC = 0x9000 + (X << 9) + (Y << 4)
    call draw_image_calc_offset
    call draw_image_copy_16_bytes

    pop b
    mvi d, 091h                 ; BC = 0x9100 + (X << 9) + (Y << 4)
    call draw_image_calc_offset
    call draw_image_copy_16_bytes

    pop b
    mvi d, 0D0h                 ; BC = 0xD000 + (X << 9) + (Y << 4)
    call draw_image_calc_offset
    call draw_image_copy_16_bytes

    pop b
    mvi d, 0D1h                 ; BC = 0xD100 + (X << 9) + (Y << 4)
    call draw_image_calc_offset
    call draw_image_copy_16_bytes

    pop b
    pop d                       ; восстановить DE
    ret

; Внутренняя подпрограмма для draw_image
; Вычисляет:
;   BC = 0x(ZZ)00 + (X << 9) + (Y << 4)
;   где ZZ передается через регистр D
;   B - координата X
;   C - координата Y
draw_image_calc_offset:
    mov a, b            
    rlc
    add d
    mov b, a
    mov a, c
    rlc
    rlc
    rlc
    rlc
    mov c, a
    ret

; Внутренняя подпрограмма для draw_image
; Копирует 16 байт из (HL) в (BC)
draw_image_copy_16_bytes:
    mvi d, 16
@@:
    mov a, m
    stax b
    inx h
    inx b
    dcr d
    jnz @B
    ret

Картинки 16x16 были нарисованы в Gimp:

Картинки для самописной игры

Я использовал сетку, нарисованную в отдельном слое. На скриншоте этот слой не показан. Затем каждое изображение 16x16 было сохранено в отдельном файле и сконвертированно в бинарный формат при помощи скрипта на Python. Код скрипта тривиален. Вы можете изучить его самостоятельно.

Fun fact! Не задумывались, почему красный цвет сочетается с бирюзовым? Потому что это комплементарные цвета, расположенные на противоположных сторонах цветового круга Иттена. Другими словами, красный (0xFF0000) — это инвертированный бирюзовый (0x00FFFF). Черный, белый и серый сочетаются со всеми цветами.

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

Ассемблер 8080 — это вам не x86/x64. Набор инструкций здесь крайне ограничен, как и количество доступных регистров. Что позволяет посмотреть на привычные алгоритмы в новом свете.

Так размер игрового поля был уменьшен до 14x18 клеток. Длина змейки при этом не может превышать 252 клетки. Для хранения X-координат и Y-координат тела змейки подойдет пара кольцевых буферов размером 256 байт. Если выровнять адреса буферов до 256 байт и расположить их последовательно, то код сильно упрощается. Например, вот процедура, проверяющая, был ли съеден фрукт во время текущего хода змейки:

    xs equ 08000h           ; буфер X-координат змейки, 256 значений
    ys equ 08100h           ; буфер Y-координат змейки, 256 значений

    head_idx equ 08200h     ; хранит текущий индекс головы, 0..255
    tail_idx equ 08201h     ; хранит текущий индекс хвоста, 0..255
    delta_x  equ 08202h     ; (dx, dy) задают направление движения
    delta_y  equ 08203h
    fruit_x  equ 08204h     ; текущие координаты фрукта
    fruit_y  equ 08205h

; Проверить, был ли съеден фрукт
; Возвращает:
;   A == 0 - фрукт не съеден
;   A != 0 - фрукт съеден
fruit_was_eaten:
    lxi h, head_idx
    mov a, m                ; A := индекс головы

    lxi h, xs               ; HL указывает на XS[0]
    mov l, a                ; HL указывает на XS[A]
    mov a, m                ; A := координата X головы

    inr h                   ; Теперь HL указывает на YS[A]
    mov b, m                ; B := координата Y головы

    lxi h, fruit_x          ; HL указывает на fruit_x
    cmp m                   ; координаты X головы и фрукта совпадают?
    jnz @F

    inx h                   ; HL указывает на fruit_y
    mov a, b
    cmp m                   ; координаты Y головы и фрукта совпадают?
    rz                      ; если да, то фрукт съеден, вернуть A != 0

@@:
    xra a                   ; фрукт не съеден, вернуть A == 0
    ret

То есть, мы можем использовать одну регистровую пару HL для указания на xs и ys одновременно. Нужен ys — говорим inr h. Снова нужен xs — делаем dcr h. Для обращения к i-му элементу буфера просто записываем индекс в регистр L. В современном коде не часто используется знание о размерах, выравнивании и относительном расположении объектов. Здесь же все это полезно и нужно.

Еще пример — как выбрать случайную клетку для расположения нового фрукта? Удачным решением оказалось сгенерировать случайные координаты заранее:

#!/usr/bin/env python3

import random

xs = range(3, 21) # [3, 20]
ys = range(1, 15) # [1, 14]

points = [ (x, y) for x in xs for y in ys ]
random.shuffle(points)

cnt = 0
print("random_xs:", end = "")
for (x, _) in points:
  if cnt % 16 == 0:
    print("\n\tdb " + str(x), end = "")
  else:
    print(", " + str(x), end = "")
  cnt += 1

print("\n\tdb 0 ; маркер конца массива\n")

cnt = 0
print("random_ys:", end = "")
for (_, y) in points:
  if cnt % 16 == 0:
    print("\n\tdb " + str(y), end = "")
  else:
    print(", " + str(y), end = "")
  cnt += 1

print()

ГПСЧ превращается в два указателя на предварительно рассчитанные массивы с X-координатами и Y-координатами. (Можно было использовать и счетчик, но почему-то я выбрал указатели, и не нашел веских причин переписывать код.) Нужны координаты — просто берем из буферов и проверяем, принадлежат ли они змейке. Если нет, то возвращаем результат. Иначе пробуем следующие случайные координаты.

Алгоритм всегда находит решение, так как перебирает все клетки игрового поля, просто не по порядку. Координаты всегда принадлежат игровому полю, безо всяких лишних вычислений (помним, что в Intel 8080 нет остатка от деления). Отсутствуют такие артефакты, как склонность фруктов «прилипать» к змейке.

Соответствующий отрывок кода:

    random_x_ptr  equ 08206h  ; указатель внутри random_xs
    random_y_ptr  equ 08208h  ; указатель внутри random_ys

; Инициализация состояния для gen_random_xy
; При возврате HL хранит значение из random_x_ptr
init_random_xy:
    lxi h, random_ys
    shld random_y_ptr       ; random_y_ptr указывает на random_ys[0]

    lxi h, random_xs
    shld random_x_ptr       ; random_x_ptr указыввает на random_xs[0]
    ret

; Сгенерировать случайную координату (X, Y) на игровом поле
; Возвращаемые значения:
;   B - координата X, 3 .. 20
;   C - координата Y, 1 .. 14
; Портит значение регистра A
gen_random_xy:
    push h

    lhld random_x_ptr       ; HL := random_x_ptr
    mov a, m
    ora a                   ; достигли маркера конца массива 000h ?
    cz init_random_xy       ; если да, вызвать init_random_xy
    mov b, m
    inx h
    shld random_x_ptr

    lhld random_y_ptr       ; HL := random_y_ptr
    mov c, m
    inx h
    shld random_y_ptr

    pop h
    ret

; Показать информацию об игре и проинициализировать ГПСЧ
show_intro:
    call init_random_xy
    call 0F9A0h             ; очистить экран
    mvi a, 18
    call 0F7BEh             ; X = 18
    mvi a, 9
    call 0F7DCh             ; Y = 9
    lxi h, hello_message
    mvi c, 0
    call 0F137h             ; вывести строку на экран

@@:
    call gen_random_xy      ; инициализация ГПСЧ
    call 0FB94h             ; опрос клавиатуры
    inr a
    jz @B                   ; A == 0FFh ?

    ret

Это наиболее интересные моменты, на которых мне хотелось бы заострить внимание. Остальной код достаточно незатейлив. Вы можете ознакомиться с полной его версией самостоятельно.

Разработка игры заняла у меня около недели, при том, что писал я ее вечерами. Размер LVT файла составил 2877 байт. Загрузка программы занимает 59 секунд.

Заключение

Так выглядит законченная игра:

Игра на ассемблере для Искры 1080 Тарту

Змейка растет, поедая яблочки и вишенки. Каждые четыре съеденных фрукта скорость игры немного увеличивается. Если змейка сталкивается со стеной или сама с собой, игра завершается, и отображается количество набранных очков. Максимум в игре можно набрать 249 очков.

Писать игры на ассемблере — это увлекательное и творческое хобби. Я от души рекомендую его всем любителям поломать голову над интересной задачкой. Рабочую игру можно долго оптимизировать по быстродействию и/или размеру. Можно ли уместить «змейку», скажем, в 512 байт? Я не знаю. Заинтересованным читателям предлагается выяснить это в качестве упражнения.

Метки: , , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.