Как я научил ИИ играть в Tetris для NES. Часть 1: анализ кода игры

Автор оригинала: meatfighter.com
  • Перевод
В этой статье я исследую обманчиво простые механики Nintendo Tetris, а во второй части расскажу, как создал ИИ, эксплуатирующий эти механики.


Попробуйте сами


О проекте


Для тех, кому не хватает упорства, терпения и времени, необходимых для освоения Nintendo Tetris, я создал ИИ, способный играть самостоятельно. Вы наконец-то сможете добраться до уровня 30 и даже дальше. Вы увидите, как получить максимальное количество очков и понаблюдаете за бесконечным изменением счётчиков рядов, уровней и статистики. Узнаете, какие цвета появляются на уровнях, выше которых не мог забраться человек. Посмотрите, насколько далеко можно зайти.

Требования


Для запуска ИИ вам понадобится универсальный эмулятор NES/Famicom FCEUX. Искусственный интеллект был разработан для FCEUX 2.2.2, самой новой версии эмулятора на время написания статьи.

Также вам понадобится ROM-файл Nintendo Tetris (версия для США). Попробуйте поискать его в Google.

Скачивание


Распакуйте lua/NintendoTetrisAI.lua из этого zip-файла с исходниками.

Запуск


Запустите FCEUX. В меню выберите File | Open ROM… В диалоговом окне Open File выберите ROM-файл Nintendo Tetris и нажмите Open. Запустится игра.

В меню выберите File | Lua | New Lua Script Window… В окне the Lua Script введите путь к NintendoTetrisAI.lua или нажмите кнопку Browse, чтобы найти его. После этого нажмите Run.

Скрипт на Lua перенаправит вас на первый экран меню. Оставьте тип игры A-Type, а музыку можете выбирать любую. На медленных компьютерах музыка может играть очень дёргано, тогда стоит её отключить. Нажмите на Start (Enter), чтобы перейти к следующему экрану меню. Во втором меню можно с помощью клавиш со стрелками изменить начальный уровень. Нажмите на Start, чтобы начать игру. И здесь управление перехватит ИИ.

Если после выбора уровня на втором экране меню зажать кнопку геймпада A (изменить раскладку клавиатуры можно в меню Config | Input...) и нажать Start, то начальный уровень будет на 10 больше выбранного значения. Максимальный начальный уровень — девятнадцатый.

Конфигурация


Чтобы игра шла быстрее, откройте скрипт Lua в текстовом редакторе. В начале файла найдите следующую строку.

PLAY_FAST = false

Замените false на true, как показано ниже.

PLAY_FAST = true

Сохраните файл. Затем нажмите кнопку Restart в окне Lua Script.

Механики Nintendo Tetris


Описание тетримино


Каждой фигуре тетримино соответствует однобуквенное название, напоминающее её форму.


Дизайнеры Nintendo Tetris произвольным образом задали показанный выше порядок тетримино. Фигуры показаны в той ориентации, в которой они появляются на экране, а схема создаёт почти симметричную картинку (возможно, поэтому выбран такой порядок). Индекс последовательности даёт каждому тетримино уникальный числовой ID. Идентификаторы последовательности и типа важны на уровне программирования; кроме того, они проявляют себя в порядке фигур, отображаемом в поле статистики (см. ниже).


19 ориентаций используемых в Nintendo Tetris тетримино закодированы в таблице, расположенной по адресу $8A9C памяти консоли NES. Каждая фигура представлена как последовательность из 12 байтов, которые можно разбить на тройки (Y, tile, X), описывающие каждый квадрат в фигуре. Указанные выше hex-значения координат выше $7F обозначают отрицательные целые числа ($FF= −1, а $FE = −2).

; Y0 T0 X0 Y1 T1 X1 Y2 T2 X2 Y3 T3 X3

8A9C: 00 7B FF 00 7B 00 00 7B 01 FF 7B 00 ; 00: T up
8AA8: FF 7B 00 00 7B 00 00 7B 01 01 7B 00 ; 01: T right
8AB4: 00 7B FF 00 7B 00 00 7B 01 01 7B 00 ; 02: T down (spawn)
8AC0: FF 7B 00 00 7B FF 00 7B 00 01 7B 00 ; 03: T left

8ACC: FF 7D 00 00 7D 00 01 7D FF 01 7D 00 ; 04: J left
8AD8: FF 7D FF 00 7D FF 00 7D 00 00 7D 01 ; 05: J up
8AE4: FF 7D 00 FF 7D 01 00 7D 00 01 7D 00 ; 06: J right
8AF0: 00 7D FF 00 7D 00 00 7D 01 01 7D 01 ; 07: J down (spawn)

8AFC: 00 7C FF 00 7C 00 01 7C 00 01 7C 01 ; 08: Z horizontal (spawn)
8B08: FF 7C 01 00 7C 00 00 7C 01 01 7C 00 ; 09: Z vertical

8B14: 00 7B FF 00 7B 00 01 7B FF 01 7B 00 ; 0A: O (spawn)

8B20: 00 7D 00 00 7D 01 01 7D FF 01 7D 00 ; 0B: S horizontal (spawn)
8B2C: FF 7D 00 00 7D 00 00 7D 01 01 7D 01 ; 0C: S vertical

8B38: FF 7C 00 00 7C 00 01 7C 00 01 7C 01 ; 0D: L right
8B44: 00 7C FF 00 7C 00 00 7C 01 01 7C FF ; 0E: L down (spawn)
8B50: FF 7C FF FF 7C 00 00 7C 00 01 7C 00 ; 0F: L left
8B5C: FF 7C 01 00 7C FF 00 7C 00 00 7C 01 ; 10: L up

8B68: FE 7B 00 FF 7B 00 00 7B 00 01 7B 00 ; 11: I vertical
8B74: 00 7B FE 00 7B FF 00 7B 00 00 7B 01 ; 12: I horizontal (spawn)

8B80: 00 FF 00 00 FF 00 00 FF 00 00 FF 00 ; 13: Unused


Внизу таблицы есть одна неиспользованная запись, потенциально дающая возможность добавления ещё одной ориентации. Однако в различных частях кода $13 обозначает, что идентификатору ориентации активного тетримино не присвоено значение.

Для простоты чтения ниже представлены координаты квадратов в десятичном виде.

-- { { X0, Y0 }, { X1, Y1 }, { X2, Y2 }, { X3, Y3 }, },

{ { -1, 0 }, { 0, 0 }, { 1, 0 }, { 0, -1 }, }, -- 00: T up
{ { 0, -1 }, { 0, 0 }, { 1, 0 }, { 0, 1 }, }, -- 01: T right
{ { -1, 0 }, { 0, 0 }, { 1, 0 }, { 0, 1 }, }, -- 02: T down (spawn)
{ { 0, -1 }, { -1, 0 }, { 0, 0 }, { 0, 1 }, }, -- 03: T left

{ { 0, -1 }, { 0, 0 }, { -1, 1 }, { 0, 1 }, }, -- 04: J left
{ { -1, -1 }, { -1, 0 }, { 0, 0 }, { 1, 0 }, }, -- 05: J up
{ { 0, -1 }, { 1, -1 }, { 0, 0 }, { 0, 1 }, }, -- 06: J right
{ { -1, 0 }, { 0, 0 }, { 1, 0 }, { 1, 1 }, }, -- 07: J down (spawn)

{ { -1, 0 }, { 0, 0 }, { 0, 1 }, { 1, 1 }, }, -- 08: Z horizontal (spawn)
{ { 1, -1 }, { 0, 0 }, { 1, 0 }, { 0, 1 }, }, -- 09: Z vertical

{ { -1, 0 }, { 0, 0 }, { -1, 1 }, { 0, 1 }, }, -- 0A: O (spawn)

{ { 0, 0 }, { 1, 0 }, { -1, 1 }, { 0, 1 }, }, -- 0B: S horizontal (spawn)
{ { 0, -1 }, { 0, 0 }, { 1, 0 }, { 1, 1 }, }, -- 0C: S vertical

{ { 0, -1 }, { 0, 0 }, { 0, 1 }, { 1, 1 }, }, -- 0D: L right
{ { -1, 0 }, { 0, 0 }, { 1, 0 }, { -1, 1 }, }, -- 0E: L down (spawn)
{ { -1, -1 }, { 0, -1 }, { 0, 0 }, { 0, 1 }, }, -- 0F: L left
{ { 1, -1 }, { -1, 0 }, { 0, 0 }, { 1, 0 }, }, -- 10: L up

{ { 0, -2 }, { 0, -1 }, { 0, 0 }, { 0, 1 }, }, -- 11: I vertical
{ { -2, 0 }, { -1, 0 }, { 0, 0 }, { 1, 0 }, }, -- 12: I horizontal (spawn)


Все ориентации помещаются в матрицу 5×5.


На показанном выше рисунке белый квадрат означает центр матрицы, опорную точку для поворота фигуры.

Ниже графически представлена таблица ориентаций.


Идентификатор ориентации (индекс таблицы) показан в шестнадцатеричном виде в правом верхнем углу каждой матрицы. А придуманная для этого проекта мнемоника показана в левом верхнем углу. u, r, d, l, h и v — это сокращения от «up, right, down, left, horizontal и vertical». Например, проще обозначать ориентацию Jd, а не $07.

Матрицы, содержащие ориентации фигур при создании, отмечены белой рамкой.

Тетримино I, S и Z можно было дать 4 отдельных ориентации, но создатели Nintendo Tetris решили ограничиться двумя. Кроме того, Zv и Sv не являются идеальными зеркальными отражениями друг друга. Обе созданы поворотом против часовой стрелки, что приводит к дисбалансу.

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

T J Z O S L I
7B 7D 7C 7B 7D 7C 7B

Значения тайлов являются индексами таблицы (псевдоцветного) паттерна, показанной ниже.


Тайлы $7B, $7C и $7D расположены прямо под «ATIS» из слова «STATISTICS». Это три типа квадратов, из которых создаются тетримино.

Для любопытных скажу, что страусы и пингвины используются в концовках режима B-Type. Эта тема подробно рассмотрена в разделе «Концовки».

Ниже показан результат модификации ROM после замены $7B на $29. Сердце — это тайл под символом P в таблице паттерна для всех ориентаций T.


Тайлы-сердца остаются на игровом поле даже после того, как модифицированные T блокируются на месте. Как сказано ниже в разделе «Создание тетримино», это означает, что игровое поле хранит настоящие значения индексов тайлов сыгранных тетримино.

Программисты игры обеспечили возможность использования для каждой фигуры 4 отдельных тайлов, а не только один неизменный тип квадратов. Это полезная фишка, которую можно использовать для модификации внешнего вида игры. В таблице паттерна очень много пустого места для новых тайлов, способных придать каждому тетримино уникальный внешний вид.

Координатами квадратов очень легко манипулировать. Например, ниже показана модифицированная версия первых четырёх троек в таблице ориентаций.

8A9C: FE 7B FE FE 7B 02 02 7B FE 02 7B 02 ; 00: T up

Это изменение аналогично следующему:

{ { -2, -2 }, { 2, -2 }, { -2, 2 }, { 2, 2 }, }, -- 00: T up

В результате получается разделённое тетримино.


При перемещении разделённого тетримино его квадраты не могут выходить за границы игрового поля и не могут проходить через ранее заблокированные на месте фигуры. Кроме того, игра запрещает поворот в этой ориентации, если он приведёт к попаданию квадрата за границы игрового поля или к тому, что квадрат наложится на уже лежащий квадрат.

Разделённое тетримино блокируется на месте, когда находится опора для любого из его квадратов. Если фигура блокируется, то висящие в воздухе квадраты продолжают висеть.

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

Кроме того, координаты квадратов могут быть любыми значениями; они не ограничены интервалом [−2, 2]. Разумеется, сильно превышающие этот интервал значения дадут нам неприменимые фигуры, которые не поместятся на игровом поле. Что более важно, как сказано в разделе «Состояния игры и режимы рендеринга», когда фигура блокируется на месте, механизм очистки заполненных линий сканирует только смещения рядов от −2 до 1 от центрального квадрата фигуры; квадрат с координатой y за пределами этого интервала окажется нераспознанным.

Вращение тетримино


В графической иллюстрации таблицы ориентаций вращение заключается в переходе от матрицы к одной из матриц слева или справа с переносом ряда при необходимости. Эта концепция закодирована в таблице по адресу $88EE.

; CCW CW
88EE: 03 01 ; Tl Tr
88F0: 00 02 ; Tu Td
88F2: 01 03 ; Tr Tl
88F4: 02 00 ; Td Tu
88F6: 07 05 ; Jd Ju
88F8: 04 06 ; Jl Jr
88FA: 05 07 ; Ju Jd
88FC: 06 04 ; Jr Jl
88FE: 09 09 ; Zv Zv
8900: 08 08 ; Zh Zh
8902: 0A 0A ; O O
8904: 0C 0C ; Sv Sv
8906: 0B 0B ; Sh Sh
8908: 10 0E ; Lu Ld
890A: 0D 0F ; Lr Ll
890C: 0E 10 ; Ld Lu
890E: 0F 0D ; Ll Lr
8910: 12 12 ; Ih Ih
8912: 11 11 ; Iv Iv


Чтобы было понятнее, каждый столбец из этой таблицы мы переместим в строку показанной ниже таблицы.
Tu Tr Td Tl Jl Ju Jr Jd Zh Zv O Sh Sv Lr Ld Ll Lu Iv Ih
Против часовой стрелки Tl Tu Tr Td Jd Jl Ju Jr Zv Zh O Sv Sh Lu Lr Ld Ll Ih Iv
По часовой стрелке Tr Td Tl Tu Ju Jr Jd Jl Zv Zh O Sv Sh Ld Ll Lu Lr Ih Iv

Мнемоники в заголовках вверху можно интерпретировать как индекс последовательности или ключ для распределения. Например, поворот против часовой стрелки Tu даёт нам Tl, а поворот по часовой стрелке Tu даёт Tr.

Таблица поворотов кодирует соединённые в цепочку последовательности ID ориентаций; следовательно, мы можем модифицировать записи таким образом, чтобы поворот преобразовывал один тип тетримино в другой. Эту технику потенциально можно использовать для извлечения пользы из неиспользованной строки в таблице ориентаций.

Перед таблицей поворотов расположен код для доступа к ней.

88AB: LDA $0042
88AD: STA $00AE ; originalOrientationID = orientationID;

88AF: CLC
88B0: LDA $0042
88B2: ASL
88B3: TAX ; index = 2 * orientationID;

88B4: LDA $00B5
88B6: AND #$80 ; if (not just pressed button A) {
88B8: CMP #$80 ; goto aNotPressed;
88BA: BNE $88CF ; }

88BC: INX
88BD: LDA $88EE,X
88C0: STA $0042 ; orientationID = rotationTable[index + 1];

88C2: JSR $948B ; if (new orientation not valid) {
88C5: BNE $88E9 ; goto restoreOrientationID;
; }

88C7: LDA #$05
88C9: STA $06F1 ; play rotation sound effect;
88CC: JMP $88ED ; return;

aNotPressed:

88CF: LDA $00B5
88D1: AND #$40 ; if (not just pressed button B) {
88D3: CMP #$40 ; return;
88D5: BNE $88ED ; }

88D7: LDA $88EE,X
88DA: STA $0042 ; orientationID = rotationTable[index];

88DC: JSR $948B ; if (new orientation not valid) {
88DF: BNE $88E9 ; goto restoreOrientationID;
; }

88E1: LDA #$05
88E3: STA $06F1 ; play rotation sound effect;
88E6: JMP $88ED ; return;

restoreOrientationID:

88E9: LDA $00AE
88EB: STA $0042 ; orientationID = originalOrientationID;

88ED: RTS ; return;


Для поворота против часовой стрелки индекс таблицы поворотов вычитается удвоением ID ориентации. Прибавлением к нему 1 мы получаем индекс поворота по часовой стрелке.

Координаты x, y и ID ориентации текущего тетримино хранятся соответственно по адресам $0040, $0041 и $0042.

Код использует временную переменную для резервного копирования ID ориентации. Позже, после изменения ориентации, код проверяет, что все четыре квадрата находятся в границах игрового поля и ни один из них не накладывается на уже лежащие квадраты (код проверки находится по адресу $948B, под показанным выше фрагментом кода). Если новая ориентация неверна, то восстанавливается исходная, не позволяя игроку повернуть фигуру.

Считая с крестовиной, у контроллера NES восемь кнопок, состояние которых представлено битом адреса $00B6.

7 6 5 4 3 2 1 0
A B Select Start Вверх Вниз Влево Вправо

Например, в $00B6 будет содержаться значение $81 пока игрок удерживает A и «Влево».

С другой стороны, $00B5 сообщает о том, когда были нажаты кнопки; биты $00B5 истинны только в течение одной итерации игрового цикла (1 отрендеренного кадра). Код использует $00B5, чтобы реагировать на нажатия A и B. Каждую из них нужно отпустить, прежде чем использовать снова.

$00B5 и $00B6 являются зеркалами $00F5 и $00F6. Код в последующих разделах использует эти адреса взаимозаменяемо.

Создание тетримино


Игровое поле Nintendo Tetris состоит из матрицы с 22 строками и 10 столбцами так, что верхние две строки скрыты от игрока.


Как показано в представленном ниже коде, при создании фигуры тетримино она всегда располагается в координатах (5, 0) игрового поля.

98BA: LDA #$00
98BC: STA $00A4
98BE: STA $0045
98C0: STA $0041 ; Tetrimino Y = 0
98C2: LDA #$01
98C4: STA $0048
98C6: LDA #$05
98C8: STA $0040 ; Tetrimino X = 5


Ниже показана наложенная на эту точку матрица размером 5×5.


Ни у одной из матриц создания нет квадратов над исходной точкой. То есть при создании тетримино все четыре его квадрата сразу становятся видны игроку. Однако если игрок быстро повернёт фигуру, прежде чем она успеет опуститься, то часть фигуры будет временно скрыта в первых двух строках игрового поля.

Обычно мы считаем, что игра завершается, когда куча достигнет вершины. Но на самом деле это не совсем так. Игра заканчивается, когда больше нет возможности создать следующую фигуру. То есть перед появлением фигуры должны быть свободны все четыре ячейки игрового поля, соответствующие позициям квадратам создаваемого тетримино. Фигура может оказаться заблокированной на месте таким образом, что часть её квадратов окажется в отрицательно пронумерованных строках, и игра при этом не закончится; однако в Nintendo Tetris отрицательные строки — это абстракция, относящаяся только к активному тетримино. После того, как фигура блокируется (становится лежащей), на поле записываются только квадраты в строках от нуля и больше. Концептуально получается, что отрицательно пронумерованные строки автоматически очищаются после блокировки. Но в реальности игра просто не хранит эти данные, отсекая верхние части фигур.

Видимая область игрового поля 20×10 хранится по адресу $0400 в построчном порядке, каждый байт содержит значение фонового тайла. Пустые клетки обозначаются тайлом $EF, сплошным чёрным квадратом.

При создании фигуры используются три таблицы поиска. При наличии произвольного ID ориентации таблица по адресу $9956 даёт нам ID ориентации при создании соответствующего типа тетримино.

9956: 02 02 02 02 ; Td
995A: 07 07 07 07 ; Jd
995E: 08 08 ; Zh
9960: 0A ; O
9961: 0B 0B ; Sh
9963: 0E 0E 0E 0E ; Ld
9967: 12 12 ; Ih


Проще показать это в таблице.

Tu Tr Td Tl Jl Ju Jr Jd Zh Zv O Sh Sv Lr Ld Ll Lu Iv Ih
Td Td Td Td Jd Jd Jd Jd Zh Zh O Sh Sh Ld Ld Ld Ld Ih Ih

Например, все ориентации J привязываются к Jd.

Таблица по адресу $993B содержит тип тетримино для заданного ID ориентации.

993B: 00 00 00 00 ; T
993F: 01 01 01 01 ; J
9943: 02 02 ; Z
9945: 03 ; O
9946: 04 04 ; S
9948: 05 05 05 05 ; L
994C: 06 06 ; I


Для понятности я покажу всё в табличной форме.

Tu Tr Td Tl Jl Ju Jr Jd Zh Zv O Sh Sv Lr Ld Ll Lu Iv Ih
T T T T J J J J Z Z O S S L L L L I I

Третью таблицу поиска мы рассмотрим в следующем разделе.

Выбор тетримино


В Nintendo Tetris в качестве псевдослучайного генератора чисел (PRNG) используется 16-битный регистр сдвига с линейной обратной связью (linear feedback shift register, LFSR) в конфигурации Фибоначчи. 16-битное значение хранится как big-endian по адресам $0017$0018. В качестве Seed используется произвольное число $8988.

80BC: LDX #$89
80BE: STX $0017
80C0: DEX
80C1: STX $0018


Каждое последующее псевдослучайное число генерируется следующим образом: значение воспринимается как 17-битное число, а наиболее значимый бит получается выполнением XOR для битов 1 и 9. Затем значение сдвигается вправо, отбрасывая наименее значимый бит.


Этот процесс происходит по адресу $AB47.

AB47: LDA $00,X
AB49: AND #$02
AB4B: STA $0000 ; extract bit 1

AB4D: LDA $01,X
AB4F: AND #$02 ; extract bit 9

AB51: EOR $0000
AB53: CLC
AB54: BEQ $AB57
AB56: SEC ; XOR bits 1 and 9 together

AB57: ROR $00,X
AB59: INX
AB5A: DEY ; right shift
AB5B: BNE $AB57 ; shifting in the XORed value

AB5D: RTS ; return


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

Для тех, кто хочет ещё больше видоизменить алгоритм, я написал его на Java.

int generateNextPseudorandomNumber(int value) {
  int bit1 = (value >> 1) & 1;
  int bit9 = (value >> 9) & 1;
  int leftmostBit = bit1 ^ bit9;
  return (leftmostBit << 15) | (value >> 1);
}

И весь этот код можно ужать до одной строки.

int generateNextPseudorandomNumber(int value) {
  return ((((value >> 9) & 1) ^ ((value >> 1) & 1)) << 15) | (value >> 1);
}

Этот PRNG непрерывно и детерминированно генерирует 32 767 уникальных значений, начиная каждый цикл с исходного seed. Это на единицу меньше половины возможных чисел, которые могут поместиться в регистр, и любое значение в этом множестве можно использовать в качестве seed. Многие из значений за пределами множества создают цепочку, которая со временем приведёт к числу из множества. Однако некоторые начальные числа в результате приводят к бесконечной последовательности нулей.

Чтобы приблизительно оценить производительность этого PRNG, я сгенерировал графическое представление создаваемых им значений, основанное на предложении с RANDOM.ORG.


При создании изображения PRNG использовался как генератор псевдослучайных чисел, а не 16-битных целых чисел. Каждый пиксель раскрашен на основании значения бита 0. Изображение имеет размер 128×256, то есть покрывает всю последовательность.

Если не считать едва заметные полосы по верхней и левой сторонам, она выглядит случайной. Не проявляется никаких очевидных паттернов.

После запуска PRNG постоянно перемешивает регистр, срабатывая не реже, чем раз в кадр. Это не происходит не только на экране заставки и экранах меню, но и при падении тетримино между операциями создания фигур. То есть от количества кадров, уходящих у игрока на размещение фигуры, зависит появляющаяся следом фигура. По сути, игра полагается на случайность действий взаимодействующего с ней человека.

Во время создания фигуры выполняется код по адресу $9907, выбирающий тип новой фигуры.

9907: INC $001A ; spawnCount++;

9909: LDA $0017 ; index = high byte of randomValue;

990B: CLC
990C: ADC $001A ; index += spawnCount;

990E: AND #$07 ; index &= 7;

9910: CMP #$07 ; if (index == 7) {
9912: BEQ $991C ; goto invalidIndex;
; }

9914: TAX
9915: LDA $994E,X ; newSpawnID = spawnTable[index];

9918: CMP $0019 ; if (newSpawnID != spawnID) {
991A: BNE $9938 ; goto useNewSpawnID;
; }

invalidIndex:

991C: LDX #$17
991E: LDY #$02
9920: JSR $AB47 ; randomValue = generateNextPseudorandomNumber(randomValue);

9923: LDA $0017 ; index = high byte of randomValue;

9925: AND #$07 ; index &= 7;

9927: CLC
9928: ADC $0019 ; index += spawnID;

992A: CMP #$07
992C: BCC $9934
992E: SEC
992F: SBC #$07
9931: JMP $992A ; index %= 7;

9934: TAX
9935: LDA $994E,X ; newSpawnID = spawnTable[index];

useNewSpawnID:

9938: STA $0019 ; spawnID = newSpawnID;

993A: RTS ; return;


По адресу $001A хранит счётчик количества фигур, созданных с включения питания. Инкремент счётчика выполняется первой строкой подпрограммы, и поскольку это однобайтный счётчик, через каждые 256 фигур он снова возвращается к нулю. Поскольку между играми счётчик не сбрасывается, история предыдущих игр влияет на процесс выбора фигуры. Это ещё один способ, которым игра использует игрока в качестве источника случайности.

Подпрограмма преобразует самый значимый байт псевдослучайного числа ($0017) в тип тетримино и использует его как индекс таблицы, расположенной по адресу $994E для преобразования типа в ID ориентации создания фигуры.

994E: 02 ; Td
994F: 07 ; Jd
9950: 08 ; Zh
9951: 0A ; O
9952: 0B ; Sh
9953: 0E ; Ld
9954: 12 ; Ih


На первом этапе преобразования к верхнему байту прибавляется счётчик созданных фигур. Затем применяется маска для сохранения только нижних 3 битов. Если результат не равен 7, то это правильный тип тетримино, и если он не такой же, как предыдущая выбранная фигура, то число используется в качестве индекса в таблице создания фигур. В противном случае генерируется следующее псевдослучайное число и применяется маска для получения нижних 3 битов верхнего байта, а затем прибавляется предыдущий ID ориентации создания фигуры. Наконец, выполняется операция по модулю для получения правильного типа тетримино, который используется как индекс в таблице создания фигур.

Поскольку процессор не поддерживает деление с остатком, этот оператор эмулируется многократным вычитанием 7, пока результат не станет меньше 7. Деление с остатком применяется к сумме верхнего байта с наложенной маской и к предыдущей ID ориентации создания фигуры. Максимальное значение этой суммы равно 25. То есть для уменьшения её до остатка 4 потребуется всего 3 итерации.

В начале каждой игры ID ориентации создания фигуры ($0019) инициализируется со значением Tu ($00). Это значение потенциально может быть использовано по адресу $9928 во время первого создания фигуры.

При использовании в сумме предыдущего ID ориентации создания фигуры, а не предыдущего типа тетримино добавляет искажение, потому что значения ID ориентации распределены не равномерно. Это показано в таблице:

$00 $02 $07 $08 $0A $0B $0E $12
0 2 0 1 3 4 0 4
1 3 1 2 4 5 1 5
2 4 2 3 5 6 2 6
3 5 3 4 6 0 3 0
4 6 4 5 0 1 4 1
5 0 5 6 1 2 5 2
6 1 6 0 2 3 6 3
7 2 0 1 3 4 0 4

В каждой ячейке содержится тип тетримино, вычисленный прибавлением ID ориентации создаваемой фигуры (столбца) к 3-битному значению (строке), а затем применением к сумме остатка от деления на 7. В каждой строке содержатся дубликаты, потому что $07 and $0E равномерно делятся на 7, а $0B и $12 имеют общий остаток. Строки 0 и 7 одинаковы, потому что они находятся на расстоянии 7.

Существует 56 возможных входных комбинаций, и если получающиеся типы тетримино распределены равномерно, то можно ожидать, что в показанной выше таблице каждый тип должен появиться ровно 8 раз. Но как показано ниже, это не так.

Тип Частота
T 9
J 8
Z 8
O 8
S 9
L 7
I 7

T и S появляются чаще, а L и I — реже. Но код с перекосом, использующий ID ориентации, не выполняется при каждом вызове подпрограммы.

Предположим, что PRNG действительно создаёт последовательность равномерно распределённых статистические независимых значений. На самом деле это справедливое допущение, учитывая то, как игра пытается получить правильную случайность из действий игрока. Прибавление количества созданных фигур по адресу $990C не повлияет на распределение, потому что между вызовами количество увеличивается равномерно. Применение битовой маски по адресу $990E аналогично применению деления на 8 с остатком, которое тоже не влияет на распределение. Следовательно, проверка по адресу $9910 переходит к invalidIndex в 1/8 всех случаев. А вероятность попадания при проверке по адресу $9918, где сравнивается новая выбранная фигура с предыдущей фигурой, равна 7/8, при вероятности совпадения в 1/7. Это значит, что существует дополнительная вероятность 7/8 × 1/7 = 1/8 оказаться в invalidIndex. В целом существует вероятность 25% использования кода с перекосом и вероятность 75% использования кода, выбирающего тетримино равномерно.

В наборе из 224 созданных тетримино математическое ожидание равно 32 экземплярам для каждого типа. Но на самом деле код создаёт следующее распределение:

Тип Частота
T 33
J 32
Z 32
O 32
S 33
L 31
I 31

То есть очистив 90 строк и достигнув уровня 9, игрок получит на одну лишнюю T и S и на одну меньше L и I, чем это ожидается статистически.

Тетримино выбираются со следующими вероятностями:

Тип Вероятность
T 14.73%
J 14.29%
Z 14.29%
O 14.29%
S 14.73%
L 13.84%
I 13.84%

Похоже, в утверждении о том, что «длинная палка» I никогда не появляется, когда она нужна, есть часть правды (по крайней мере, для Nintendo Tetris).

Сдвиг тетримино


Nintendo Tetris используется отложенный автоматический сдвиг (Delayed Auto Shift, DAS). Нажатие на «Влево» или «Вправо» мгновенно перемещает тетримино на одну ячейку по горизонтали. В то время как удерживание одной из этих кнопок направлений заставляет игру автоматически сдвигать фигуру через каждые 6 кадров с изначальной задержкой в 16 кадров.

Такой вид горизонтального движения управляется кодом по адресу $89AE.

89AE: LDA $0040
89B0: STA $00AE ; originalX = tetriminoX;

89B2: LDA $00B6 ; if (pressing down) {
89B4: AND #$04 ; return;
89B6: BNE $8A09 ; }

89B8: LDA $00B5 ; if (just pressed left/right) {
89BA: AND #$03 ; goto resetAutorepeatX;
89BC: BNE $89D3 ; }

89BE: LDA $00B6 ; if (not pressing left/right) {
89C0: AND #$03 ; return;
89C2: BEQ $8A09 ; }

89C4: INC $0046 ; autorepeatX++;
89C6: LDA $0046 ; if (autorepeatX < 16) {
89C8: CMP #$10 ; return;
89CA: BMI $8A09 ; }

89CC: LDA #$0A
89CE: STA $0046 ; autorepeatX = 10;
89D0: JMP $89D7 ; goto buttonHeldDown;

resetAutorepeatX:

89D3: LDA #$00
89D5: STA $0046 ; autorepeatX = 0;

buttonHeldDown:

89D7: LDA $00B6 ; if (not pressing right) {
89D9: AND #$01 ; goto notPressingRight;
89DB: BEQ $89EC ; }

89DD: INC $0040 ; tetriminoX++;
89DF: JSR $948B ; if (new position not valid) {
89E2: BNE $8A01 ; goto restoreX;
; }

89E4: LDA #$03
89E6: STA $06F1 ; play shift sound effect;
89E9: JMP $8A09 ; return;

notPressingRight:

89EC: LDA $00B6 ; if (not pressing left) {
89EE: AND #$02 ; return;
89F0: BEQ $8A09 ; }

89F2: DEC $0040 ; tetriminoX--;
89F4: JSR $948B ; if (new position not valid) {
89F7: BNE $8A01 ; goto restoreX;
; }

89F9: LDA #$03
89FB: STA $06F1 ; play shift sound effect;
89FE: JMP $8A09 ; return;

restoreX:

8A01: LDA $00AE
8A03: STA $0040 ; tetriminoX = originalX;

8A05: LDA #$10
8A07: STA $0046 ; autorepeatX = 16;

8A09: RTS ; return;


Как и в коде вращения, здесь используется временная переменная для резервного копирования координаты x на случай, если новая позиция окажется неправильной.

Заметьте, что проверка мешает сдвигать фигуру, пока игрок нажимает «Вниз».

Бросание тетримино


Скорость автоматического спуска тетримино — это функция от номера уровня. Скорости кодируются как количество отрендеренных кадров на спуск в таблице, расположенной по адресу $898E. Так как NES работает с частотой 60,0988 кадров/с, можно вычислить период между спусками и скорость.

Уровень Кадров на спуск Период (с/спуск) Скорость (ячеек/с)
0 48 .799 1.25
1 43 .715 1.40
2 38 .632 1.58
3 33 .549 1.82
4 28 .466 2.15
5 23 .383 2.61
6 18 .300 3.34
7 13 .216 4.62
8 8 .133 7.51
9 6 .100 10.02
10–12 5 .083 12.02
13–15 4 .067 15.05
16–18 3 .050 20.03
19–28 2 .033 30.05
29+ 1 .017 60.10

В таблице всего 30 записей. После уровня 29 значение кадров на спуск всегда равно 1.

Целое число кадров на спуск — это не особо детализированный способ описания скорости. Как показано на графике ниже, скорость растёт с каждым уровнем экспоненциально. На самом деле уровень 29 в два раза быстрее, чем уровень 28.


При 1 кадре/спуск у игрока есть не больше 1/3 секунды на расположение фигуры, прежде чем она начнёт двигаться. На этой скорости спуска DAS не позволяет фигуре достигнуть краёв игрового поля до блокирования на месте, что означает для большинства людей быстрый конец игры. Однако некоторым игрокам, в частности Тору Акерлунду, удалось победить DAS быстрой вибрацией кнопок крестовины (D-pad). В показанном выше коде сдвига видно, что пока кнопка горизонтального направления отпускается через кадр, возможно сдвигать тетримино на уровнях 29 и выше с половинной частотой. Это теоретический максимум, но любая вибрация большого пальца выше 3,75 нажатий/с может победить исходную задержку в 16 кадров.

Если автоматический и управляемый игроком спуск (нажатием «Вниз») совпадают и происходят в одном кадре, эффект не складывается. Любой или оба из этих событий заставляют фигуру опуститься вниз в этом кадре ровно на одну ячейку.

Контролирующая спуск логика находится по адресу $8914.

8914: LDA $004E ; if (autorepeatY > 0) {
8916: BPL $8922 ; goto autorepeating;
; } else if (autorepeatY == 0) {
; goto playing;
; }

; game just started
; initial Tetrimino hanging at spawn point

8918: LDA $00B5 ; if (not just pressed down) {
891A: AND #$04 ; goto incrementAutorepeatY;
891C: BEQ $8989 ; }

; player just pressed down ending startup delay

891E: LDA #$00
8920: STA $004E ; autorepeatY = 0;
8922: BNE $8939

playing:

8924: LDA $00B6 ; if (left or right pressed) {
8926: AND #$03 ; goto lookupDropSpeed;
8928: BNE $8973 ; }

; left/right not pressed

892A: LDA $00B5
892C: AND #$0F ; if (not just pressed only down) {
892E: CMP #$04 ; goto lookupDropSpeed;
8930: BNE $8973 ; }

; player exclusively just presssed down

8932: LDA #$01
8934: STA $004E ; autorepeatY = 1;

8936: JMP $8973 ; goto lookupDropSpeed;

autorepeating:

8939: LDA $00B6
893B: AND #$0F ; if (down pressed and not left/right) {
893D: CMP #$04 ; goto downPressed;
893F: BEQ $894A ; }

; down released

8941: LDA #$00
8943: STA $004E ; autorepeatY = 0
8945: STA $004F ; holdDownPoints = 0
8947: JMP $8973 ; goto lookupDropSpeed;

downPressed:

894A: INC $004E ; autorepeatY++;
894C: LDA $004E
894E: CMP #$03 ; if (autorepeatY < 3) {
8950: BCC $8973 ; goto lookupDropSpeed;
; }

8952: LDA #$01
8954: STA $004E ; autorepeatY = 1;

8956: INC $004F ; holdDownPoints++;

drop:

8958: LDA #$00
895A: STA $0045 ; fallTimer = 0;

895C: LDA $0041
895E: STA $00AE ; originalY = tetriminoY;

8960: INC $0041 ; tetriminoY++;
8962: JSR $948B ; if (new position valid) {
8965: BEQ $8972 ; return;
; }

; the piece is locked

8967: LDA $00AE
8969: STA $0041 ; tetriminoY = originalY;

896B: LDA #$02
896D: STA $0048 ; playState = UPDATE_PLAYFIELD;
896F: JSR $9CAF ; updatePlayfield();

8972: RTS ; return;

lookupDropSpeed:

8973: LDA #$01 ; tempSpeed = 1;

8975: LDX $0044 ; if (level >= 29) {
8977: CPX #$1D ; goto noTableLookup;
8979: BCS $897E ; }

897B: LDA $898E,X ; tempSpeed = framesPerDropTable[level];

noTableLookup:

897E: STA $00AF ; dropSpeed = tempSpeed;

8980: LDA $0045 ; if (fallTimer >= dropSpeed) {
8982: CMP $00AF ; goto drop;
8984: BPL $8958 ; }

8986: JMP $8972 ; return;

incrementAutorepeatY:

8989: INC $004E ; autorepeatY++;
898B: JMP $8972 ; return;


Таблица кадров на спуск находится под меткой lookupDropSpeed. Как сказано выше, на уровне 29 и выше скорость постоянно равна 1 спуску/кадр.

fallTimer (адрес $0045) запускает спуск, когда достигает dropSpeed ($00AF). Инкремент fallTimer выполняется по адресу $8892 за пределами этого фрагмента кода. При автоматическом или управляемом спуске он сбрасывается на 0.

Переменная autorepeatY ($004E) инициализируется со значением $0A (по адресу $8739), которое интерпретируется как −96. Условие в самом начале вызывает начальную задержку. Самое первое тетримино остаётся подверженной в воздухе в точке создания, пока autorepeatY не увеличится до 0, что занимает 1,6 секунды. Однако при нажатии «Вниз» в этой фазе autorepeatY мгновенно присваивается 0. Интересно, что можно сдвигать и вращать фигуру в этой фазе начальной задержки, не отменяя её.

Инкремент autorepeatY выполняется при удерживании «Вниз». Когда он достигает 3, происходит управляемый человеком спуск («мягкий» спуск) и autorepeatY присваивается 1. Следовательно, начальному мягкому спуску требуется 3 кадра, но затем он повторяется в каждом кадре.

Кроме того, autorepeatY увеличивается с 0 до 1 только когда игра распознаёт, что игрок только что нажал «Вниз» (по адресу $00B5), а не распознаёт удерживание «Вниз». Это важно, потому что autorepeatY сбрасывается на 0 при создании тетримино (по адресу $98E8), что создаёт важную особенность: если игрок сам спускает фигуру и она блокируется, а он продолжает нажимать «Вниз» при создании следующей фигуры, что часто происходит на высоких уровнях, то это не приведёт к мягкому спуску новой фигуры. Чтобы он произошёл, игрок должен отпустить «Вниз», а потом снова нажать кнопку.

Потенциально мягкий спуск может увеличить количество очков. holdDownPoints ($004F) увеличивается при каждом спуске, но при нажатии «Вниз» сбрасывается на 0. Поэтому для набора очков необходимо опустить тетримино в блокировку мягким спуском. Кратковременный мягкий спуск, который может произойти на пути фигуры, не влияет на очки. Счёт обновляется по адресу $9BFE, а holdDownPoints сбрасывается на 0 вскоре после этого, по адресу $9C2F.

Проверка, мешающая игроку выполнять мягкий спуск при горизонтальном сдвиге фигуры, усложняет набор очков. Он значит, что последним ходом перед блокировкой фигуры на месте должен быть «Вниз».

Когда происходит спуск, tetriminoY ($0041) копируется в originalY ($00AE). Если новая позиция, создаваемая инкрементом tetriminoY, оказывается неправильной (то есть фигура или проталкивается сквозь пол игрового поля, или накладывается на уже лежащие квадраты), то тетримино остаётся в предыдущей позиции. В этом случае восстанавливается tetriminoY и фигура считается заблокированной. Это значит, что задержка перед блокировкой (максимальное количество кадров, которое ожидает тетримино, удерживаясь в воздухе перед блокировкой) равна задержке спуска.

Жёсткий спуск (мгновенное падение фигуры) в Nintendo Tetris не поддерживается.

Скольжение и прокручивание


В буклете руководства Nintendo Tetris есть иллюстрированный пример выполнения скольжения:


Скольжение заключается в сдвиге вдоль поверхности других фигур или вдоль пола игрового поля. Обычно оно используется для того, чтобы засунуть фигуру под нависающий квадрат. Скольжение можно выполнять, пока таймер падения не достигнет скорости спуска, после чего фигура будет заблокирована на месте. Ниже показан анимированный пример.


С другой стороны, прокручивание позволяет засунуть фигуры в пространства, недостижимые никаким другим способом (см. ниже).


Как и скольжение, прокручивание невозможно без задержки блокировки. Но кроме этого прокручивание эксплуатирует способ, которым игра манипулирует фигурами. Перед перемещением или поворотом фигуры игра проверяет, что после изменения позиции все квадраты тетримино будут находиться в пустых ячейках в границах игрового поля. Такая проверка, как показано ниже, не препятствует повороту сквозь ближние заполненные блоки.

948B: LDA $0041
948D: ASL
948E: STA $00A8
9490: ASL
9491: ASL
9492: CLC
9493: ADC $00A8
9495: ADC $0040
9497: STA $00A8

9499: LDA $0042
949B: ASL
949C: ASL
949D: STA $00A9
949F: ASL
94A0: CLC
94A1: ADC $00A9
94A3: TAX ; index = 12 * orientationID;
94A4: LDY #$00

94A6: LDA #$04
94A8: STA $00AA ; for(i = 0; i < 4; i++) {

94AA: LDA $8A9C,X ; squareY = orientationTable[index];
94AD: CLC
94AE: ADC $0041 ; cellY = squareY + tetriminoY;
94B0: ADC #$02 ; if (cellY < -2 || cellY >= 20) {
94B2: CMP #$16 ; return false;
94B4: BCS $94E9 ; }

94B6: LDA $8A9C,X
94B9: ASL
94BA: STA $00AB
94BC: ASL
94BD: ASL
94BE: CLC
94BF: ADC $00AB
94C1: CLC
94C2: ADC $00A8
94C4: STA $00AD

94C6: INX
94C7: INX ; index += 2;

94C8: LDA $8A9C,X ; squareX = orientationTable[index];
94CB: CLC
94CC: ADC $00AD
94CE: TAY ; cellX = squareX + tetriminoX;
94CF: LDA ($B8),Y ; if (playfield[10 * cellY + cellX] != EMPTY_TILE) {
94D1: CMP #$EF ; return false;
94D3: BCC $94E9 ; }

94D5: LDA $8A9C,X
94D8: CLC
94D9: ADC $0040 ; if (cellX < 0 || cellX >= 10) {
94DB: CMP #$0A ; return false;
94DD: BCS $94E9 ; }

94DF: INX ; index++;
94E0: DEC $00AA
94E2: BNE $94AA ; }

94E4: LDA #$00
94E6: STA $00A8
94E8: RTS ; return true;

94E9: LDA #$FF
94EB: STA $00A8
94ED: RTS


Как сказано в разделе «Описание тетримино», каждая строка таблицы ориентаций содержит 12 байтов; следовательно, индекс в этой таблице вычисляется умножением ID ориентации активного тетримино на 12. Как показано ниже, все умножения в подпрограмме выполняются с помощью сдвигов и сложения.

index = (orientationID << 3) + (orientationID << 2); // index = 8 * orientationID + 4 * orientationID;

(cellY << 3) + (cellY << 1) // 8 * cellY + 2 * cellY


Каждая итерация цикла смещает позицию тетримино на относительные координаты одного из квадратов из таблицы ориентаций, чтобы получить соответсвующее место ячейки на игровом поле. Затем она проверяет, что координаты ячейки находятся в пределах границ игрового поля, и что сама ячейка пуста.

В комментариях понятнее описан способ выполнения проверки интервала строк.

94AA: LDA $8A9C,X ; squareY = orientationTable[index];
94AD: CLC
94AE: ADC $0041 ; cellY = squareY + tetriminoY;
94B0: ADC #$02 ; if (cellY + 2 >= 22) {
94B2: CMP #$16 ; return false;
94B4: BCS $94E9 ; }


В дополнение к ячейкам в видимых строках, код считает две скрытые строки над игровом полем законными позициями квадратов без использования составного условия. Это срабатывает, потому что в дополнительном коде отрицательные числа, представленные однобайтными переменными, эквивалентны значениям больше 127. В этом случае минимальное зенчение cellY равно −2, что хранится как $FE (254 в десятичном представлении).

Индекс игрового поля — это сумма cellY, умноженная на 10 и cellX. Однако, когда cellY равен −1 ($FF = 255) или −2 ($FE = 254), в произведении получается −10 ($F6 = 246) и −20 ($EC = 236). Находясь в интервале, cellX может быть не больше 9, что даёт максимальный индекс 246 + 9 = 255, а это намного дальше конца игрового поля. Однако игра инициализирует $0400$04FF со значением $EF (пустого тайла), создавая ещё 56 дополнительных байтов пустого места.

Странно, что проверка интервала cellX выполняется после исследования ячейки игрового поля. Но это работает правильно в любом порядке. Кроме того, проверка интервала избегает составного условия, как это ниже обозначено в комментарии.

94D5: LDA $8A9C,X
94D8: CLC
94D9: ADC $0040 ; if (cellX >= 10) {
94DB: CMP #$0A ; return false;
94DD: BCS $94E9 ; }


Показанные ниже примеры прокручивания возможны благодаря тому способу, которым этот код проверяет позиции.



Как показано ниже, можно даже выполнять скольжение с прокручиванием.


ИИ пользуется всеми возможностями перемещения, имеющимимся в Nintendo Tetris, в том числе и скольжением с прокручиванием.

Уровень 30 и выше


После достижения уровня 30 кажется, что уровень сбрасывается на ноль.


Но уровень 31 показывает, что происходит нечто иное:


Отображаемые значения уровней расположены в таблице по адресу $96B8.

96B8: 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29

Как показано ниже, таблица паттерна упорядочена таким образом, что тайлы с $00 по $0F являются глифами для символов с 0 по F. Это означает, что при отображении цифры десятичного или шестнадцатеричного числа в качестве индекса таблицы паттерна используется само значение цифры. В нашем случае значения уровней хранятся как двоично-десятичный код (binary-coded decimal, BCD); каждый полубайт каждого байта в последовательности является значением тайла.


К сожалению, похоже, что дизайнеры игры предполагали, что никто не пройдёт уровень 29, а потому решили вставить в таблицу всего 30 записей. Странные отображаемые значения — это разные байты после таблицы. Для обозначения номера уровня используется только один байт (по адресу $0044), из-за чего игра медленно циклично обходит показанные ниже 256 значений.

00 0 1 2 3 4 5 6 7 8 9 A B C D E F
0 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15
1 16 17 18 19 20 21 22 23 24 25 26 27 28 29 00 0A
2 14 1E 28 32 3C 46 50 5A 64 6E 78 82 8C 96 A0 AA
3 B4 BE C6 20 E6 20 06 21 26 21 46 21 66 21 86 21
4 A6 21 C6 21 E6 21 06 22 26 22 46 22 66 22 86 22
5 A6 22 C6 22 E6 22 06 23 26 23 85 A8 29 F0 4A 4A
6 4A 4A 8D 07 20 A5 A8 29 0F 8D 07 20 60 A6 49 E0
7 15 10 53 BD D6 96 A8 8A 0A AA E8 BD EA 96 8D 06
8 20 CA A5 BE C9 01 F0 1E A5 B9 C9 05 F0 0C BD EA
9 96 38 E9 02 8D 06 20 4C 67 97 BD EA 96 18 69 0C
A 8D 06 20 4C 67 97 BD EA 96 18 69 06 8D 06 20 A2
B 0A B1 B8 8D 07 20 C8 CA D0 F7 E6 49 A5 49 C9 14
C 30 04 A9 20 85 49 60 A5 B1 29 03 D0 78 A9 00 85
D AA A6 AA B5 4A F0 5C 0A A8 B9 EA 96 85 A8 A5 BE
E C9 01 D0 0A A5 A8 18 69 06 85 A8 4C BD 97 A5 B9
F C9 04 D0 0A A5 A8 38 E9 02 85 A8 4C BD 97 A5 A8

Первые 20 порядковых значений — это на самом деле другая таблица, в которой хранятся смещения на игровом поле для каждой из 20 строк.

96D6: 00 ; 0
96D7: 0A ; 10
96D8: 14 ; 20
96D9: 1E ; 30
96DA: 28 ; 40
96DB: 32 ; 50
96DC: 3C ; 60
96DD: 46 ; 70
96DE: 50 ; 80
96DF: 5A ; 90
96E0: 64 ; 100
96E1: 6E ; 110
96E2: 78 ; 120
96E3: 82 ; 130
96E4: 8C ; 140
96E5: 96 ; 150
96E6: A0 ; 160
96E7: AA ; 170
96E8: B4 ; 180
96E9: BE ; 190


Поскольку игровое поле начинается с $0400 и каждая строка содержит 10 ячеек, адрес произвольной ячейки равен:

$0400 + 10 * y + x

Учитывая, что процессор не поддерживает умножение напрямую, эта таблица поиска обеспечивает чрезвычайно быстрый способ получения произведения.

$0400 + [$96D6 + y] + x

Соответствующая таблица занимает следующие 40 байт. В ней содержатся 20 адресов в формате little endian для nametable 0 (области памяти VRAM, содержащей значения фоновых тайлов). Они являются указателями на строки смещения игрового поля на $06.

Оставшиеся байты, из которых составлены отображаемые значения уровней, являются инструкциями.

Ряды и статистика


Количество заполненных рядов и статистика тетримино занимают по 2 байта каждый по следующим адресам.

Адреса Количество
00500051 Ряды
03F003F1 T
03F203F3 J
03F403F5 Z
03F603F7 O
03F803F9 S
03FA03FB L
03FC03FD I

По сути, эти значения хранятся как 16-битные упакованные BCD little endian. Например, ниже показано количество рядов, равное 123. Байты считаются справа налево, чтобы десятичные цифры шли по порядку.


Однако дизайнеры игры предполагали, что ни одно из значений не будет больше 999. Поэтому логика отображения правильно обрабатывает первый байт как упакованный BCD, где каждый полубайт используется как значение тайла. Но весь второй байт на самом деле используется как верхняя десятичная цифра. Когда нижние цифры переходят от 99 к 00 происходит обычный инкремент второго байта. В результате второй байт циклически проходит по всем 256 тайлам. Ниже показан пример этого.


После очистки строки для инкремента количества рядов выполняется следующий код.

9BA8: INC $0050 ; increment middle-lowest digit pair
9BAA: LDA $0050
9BAC: AND #$0F
9BAE: CMP #$0A ; if (lowest digit > 9) {
9BB0: BMI $9BC7
9BB2: LDA $0050
9BB4: CLC
9BB5: ADC #$06 ; set lowest digit to 0, increment middle digit
9BB7: STA $0050
9BB9: AND #$F0
9BBB: CMP #$A0 ; if (middle digit > 9) {
9BBD: BCC $9BC7
9BBF: LDA $0050
9BC1: AND #$0F
9BC3: STA $0050 ; set middle digit to 0
9BC5: INC $0051 ; increment highest digit
; }
; }


Проверки выполняются для средних и нижних цифр, чтобы они оставались пределах от 0 до 9. Но верхнюю цифру можно увеличивать бесконечно.

Если после инкремента количества рядов нижняя цифра равна 0, то это значит, что игрок только что закончил набор из 10 строк и нужно увеличить номер уровня. Как видно из показанного ниже кода, перед инкрементом уровня выполняется дополнительная проверка.

9BC7: LDA $0050
9BC9: AND #$0F
9BCB: BNE $9BFB ; if (lowest digit == 0) {
9BCD: JMP $9BD0

9BD0: LDA $0051
9BD2: STA $00A9
9BD4: LDA $0050
9BD6: STA $00A8 ; copy digits from $0050-$0051 to $00A8-$00A9

9BD8: LSR $00A9
9BDA: ROR $00A8
9BDC: LSR $00A9
9BDE: ROR $00A8
9BE0: LSR $00A9
9BE2: ROR $00A8 ; treat $00A8-$00A9 as a 16-bit packed BCD value
9BE4: LSR $00A9 ; and right-shift it 4 times
9BE6: ROR $00A8 ; this leaves the highest and middle digits in $00A8

9BE8: LDA $0044
9BEA: CMP $00A8 ; if (level < [$00A8]) {
9BEC: BPL $9BFB

9BEE: INC $0044 ; increment level
; }
; }


Вторая проверка связана с выбранным начальным уровнем. Чтобы перейти на какой-то уровень X, вне зависимости от начального уровня игрок должен очистить 10X строк. Например, если игрок начинает с уровня 5, он останется на нём, пока не очистит 60 строк, после чего перейдёт на уровень 6. После этого каждые дополнительные 10 строк будут приводить к инкременту номера уровня.

Для выполнения этой проверки значение заполненных рядов копируется из $0050$0051 в $00A8$00A9. Затем копия 4 раза сдвигается вправо, что для упакованного BCD аналогично делению на 10. Самая младшая десятичная цифра отбрасывается, а старшая и средняя цифры сдвигаются на одну позицию, в результате становясь полубайтами $00A8.


Однако по адресу $9BEA номер уровня напрямую сравнивается с упакованным значением BCD в $00A8. Поиск в таблице для преобразования значения BCD в десятичное отсутствует, и это явная ошибка. Например, на показанной выше картинке, номер уровня должен сравниваться с $12 (18 в десятичном виде), а не с 12. Следовательно, если игрок решит начать с уровня 17, то уровень на самом деле перейдёт на 120 рядов, потому что 18 больше, чем 17.

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

Начальный уровень 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
Ожидаемое количество рядов 10 20 30 40 50 60 70 80 90 100 110 120 130 140 150 160 170 180 190 200
Количество рядов на самом деле 10 20 30 40 50 60 70 80 90 100 100 100 100 100 100 100 110 120 130 140

Ожидаемое количество совпадает с истинным для начальных уровней 0–9. На самом деле, совпадение для начального уровня 9 случайно; 10–15 тоже переходит на следующий уровень при 100 рядах, потому что $10 — это 16 в десятичном виде. Наибольшая разница между ожидаемым и действительным — 60 рядов.

Я подозреваю, что баг связан с изменениями дизайна на поздних этапах разработки. Посмотрите на экран меню, позволяющий игроку выбирать начальный уровень.


Нет пояснения, как начинать с уровней выше 9. Но в буклете руководства Nintendo Tetris этот секрет раскрыт:


Похоже, что эту скрытую функцию придумали в последний момент. Возможно, её добавили очень близко к дате выпуска, что не позволило полностью её протестировать.

На самом деле проверка начальных рядов содержит вторую ошибку, связанную с выходом значений за интервал. Ниже показаны комментарии в коде, лучше объясняющие, что происходит на низком уровне.

9BE8: LDA $0044
9BEA: CMP $00A8 ; if (level - [$00A8] < 0) {
9BEC: BPL $9BFB

9BEE: INC $0044 ; increment level
; }


Сравнение выполняется вычитанием и проверкой знака результата. Но однобайтное число со знаком ограничено интервалом от −128 до 127. Если разность меньше, чем −128, то число переносится и результат становится положительным числом. Этот принцип объяснён в комментариях к коду.

9BE8: LDA $0044 ; difference = level - [$00A8];
9BEA: CMP $00A8 ; if (difference < 0 && difference >= -128) {
9BEC: BPL $9BFB

9BEE: INC $0044 ; increment level
; }


При проверке того, что разность окажется в этом интервале, нужно учитывать, что номер уровня при инкременте до значений больше 255 выполняет перенос к 0, а $00A8 потенциально может содержать любое значение, потому что его верхний полубайт берётся из $0051, инкремент которого может происходит бесконечно.

Эти эффекты накладываются друг на друга, создавая периоды, в которых номер уровня ошибочно остаётся неизменным. Периоды возникают с постоянными интервалами в 2900 рядов, начиная с 2190 рядов, и длятся в течение 800 рядов. Например, с 2190 (L90) по 2990 (T90) уровень остаётся равным $DB (96), как показано ниже.


Следующий период случается с 5090 по 5890, уровень постоянно равен $AD (06). Кроме того, во время этих периодов цветовая палитра тоже не меняется.

Раскраска тетримино


На каждом уровне тайлам тетримино назначается 4 уникальных цвета. Цвета берутся из таблицы, находящейся по адресу $984C. Её записи повторно используются через каждые 10 уровней.

984C: 0F 30 21 12 ; level 0
9850: 0F 30 29 1A ; level 1
9854: 0F 30 24 14 ; level 2
9858: 0F 30 2A 12 ; level 3
985C: 0F 30 2B 15 ; level 4
9860: 0F 30 22 2B ; level 5
9864: 0F 30 00 16 ; level 6
9868: 0F 30 05 13 ; level 7
986C: 0F 30 16 12 ; level 8
9870: 0F 30 27 16 ; level 9


Слева направо: столбцы таблицы, соответствующие чёрной, белой, синей и красной областям показанного ниже изображения.


Значения соответствуют цветовой палитре NES.


Первые 2 цвета каждой записи — это всегда чёрный и белый. Однако на самом деле первый цвет игнорируется; вне зависимости от значения он считается прозрачным цветом, через который проглядывает сплошной чёрный фон.

Доступ к таблице цветов выполняется в подпрограмме по адресу $9808.

9808: LDA $0064
980A: CMP #$0A
980C: BMI $9814
980E: SEC
980F: SBC #$0A
9811: JMP $980A ; index = levelNumber % 10;

9814: ASL
9815: ASL
9816: TAX ; index *= 4;

9817: LDA #$00
9819: STA $00A8 ; for(i = 0; i < 32; i += 16) {

981B: LDA #$3F
981D: STA $2006
9820: LDA #$08
9822: CLC
9823: ADC $00A8
9825: STA $2006 ; palette = $3F00 + i + 8;

9828: LDA $984C,X
982B: STA $2007 ; palette[0] = colorTable[index + 0];

982E: LDA $984D,X
9831: STA $2007 ; palette[1] = colorTable[index + 1];

9834: LDA $984E,X
9837: STA $2007 ; palette[2] = colorTable[index + 2];

983A: LDA $984F,X
983D: STA $2007 ; palette[3] = colorTable[index + 3];

9840: LDA $00A8
9842: CLC
9843: ADC #$10
9845: STA $00A8
9847: CMP #$20
9849: BNE $981B ; }

984B: RTS ; return;


Индекс таблицы цветов основан на номере уровня, поделённом с остатком на 10. Цикл копирует запись в таблицы палитр в памяти VRAM.

Деление с остатком эмулируется постоянным вычитанием 10, пока результат не станет меньше 10. Ниже показано начало подпрограммы с комментариями.

9808: LDA $0064 ; index = levelNumber;
980A: CMP #$0A ; while(index >= 10) {
980C: BMI $9814
980E: SEC
980F: SBC #$0A ; index -= 10;
9811: JMP $980A ; }


Однако, как сказано в предыдущем разделе, в сравнении используется вычитание и ветвление на основе знака разности. А однобайтное число со знаком ограничено интервалом от −128 до 127. Обновлённые комментарии ниже отражают этот принцип.

9808: LDA $0064 ; index = levelNumber;
; difference = index - 10;
980A: CMP #$0A ; while(difference >= 0 && difference <= 127) {
980C: BMI $9814
980E: SEC ; index -= 10;
980F: SBC #$0A ; difference = index - 10;
9811: JMP $980A ; }


Комментарии ниже ещё больше упрощены.

9808: LDA $0064 ; index = levelNumber;
980A: CMP #$0A ; while(index >= 10 && index <= 137) {
980C: BMI $9814
980E: SEC
980F: SBC #$0A ; index -= 10;
9811: JMP $980A ; }


Такая формулировка выявляет ошибку в коде. Операция деления с остатком полностью пропускается для уровней от 138 и выше. Вместо этого индексу присваивается непосредственно номер уровня, что предоставляет доступ к байтам далеко за концом таблицы цветов. Как показано ниже, это даже может привести к почти невидимым тетримино.


Ниже показаны цвета всех 256 уровней. Тайлы выстроены в 10 столбцов, чтобы подчеркнуть циклическое использование таблицы цветов, нарушаемое на уровне 138. Строки и столбцы в заголовках указаны в десятичном виде.


После 255 номер уровня возвращается к 0.

Кроме того, как упомянуто в предыдущем разделе, некоторые уровни не сменяются, пока не убрано 800 рядов. В течение этих долгих уровней цвета остаются неизменными.

Режим игры


Режим игры, хранящийся по адресу $00C0 определяет, какой из различных экранов и меню показывается пользователю в текущий момент.

Значение Описание
00 Экран с юридической информацией
01 Экран заставки
02 Меню типа игры
03 Меню уровней и высоты
04 Игра / экран рекордов / концовка / пауза
05 Демо

Как показано выше, в игре есть умно написанная подпрограмма, выполняющая роль оператора switch с помощью таблицы переходов little endian, расположенной непосредственно после вызова.

8161: LDA $00C0
8163: JSR $AC82 ; switch(gameMode) {
8166: 00 82 ; case 0: goto 8200; // Экран с юридической информацией
8168: 4F 82 ; case 1: goto 824F; // Экран заставки
816A: D1 82 ; case 2: goto 82D1; // Меню типа игры
816C: D7 83 ; case 3: goto 83D7; // Меню уровней и высоты
816E: 5D 81 ; case 4: goto 815D; // Игра / экран рекордов / концовка / пауза
8170: 5D 81 ; case 5: goto 815D; // Демо
; }


В показанном выше списке указаны адреса всех режимов игры. Заметьте, что режимы «Игра» и «Демо» используют одинаковый код.

Эта подпрограмма никогда не выполняет возврат. Вместо этого код пользуется адресом возврата; обычно он указывает на инструкцию, следующую сразу за вызовом перехода к подпрограмме (минус 1 байт), но в этом случае он указывает на таблицу переходов. Адрес возврата извлекается из стека и сохраняется в $0000$0001. После сохранения адреса таблицы переходов код использует значение в регистре A в качестве индекса и выполняет соответствующий переход.

AC82: ASL
AC83: TAY
AC84: INY

AC85: PLA
AC86: STA $0000
AC88: PLA ; pop return address off of stack
AC89: STA $0001 ; and store it at $0000-$0001

AC8B: LDA ($00),Y
AC8D: TAX
AC8E: INY
AC8F: LDA ($00),Y
AC91: STA $0001
AC93: STX $0000
AC95: JMP ($0000) ; goto Ath 16-bit address
; in table at [$0000-$0001]


Код может пользоваться этой switch-подпрограммой, пока индексы близки к 0 и между возможными case нет пробелов или их мало.

Экран с юридической информацией


Игра запускается с экраном, на котором показано юридическое уведомление.


Внизу экрана упоминается Алексей Пажитнов как изобретатель, дизайнер и программист первого «Тетриса». В 1984 году, работая компьютерным разработчиком в Вычислительном центре имени Дородницына (ведущем исследовательском институте Российской Академии Наук в Москве), он разработал прототип игры на «Электронике-60» (советском клоне DEC LSI-11). Прототип разрабатывался для зелёного монохромного текстового режима, в котором квадраты обозначались парами квадратных скобок []. С помощью 16-летнего школьника Вадима Герасимова и компьютерного инженера Дмитрия Павловского спустя несколько дней после изобретения игры прототип был портирован на IBM PC с MS DOS и Turbo Pascal. На протяжении двух лет они вместе усовершенствовали игру, добавляя такие черты, как цвета тетримино, статистику и, что более важно, код таймингов и графики, позволивший игре работать на множестве моделей и клонов PC.

К сожалению, из-за особенностей Советского Союза того времени, их попытки монетизировать игру не увенчались успехом и в конце концов они решили бесплатно поделиться с друзьями PC-версией. С этого момента «Тетрис» начал вирально распространяться по стране и за её пределы, копируемый с дискеты на дискету. Но так как игра разрабатывалась сотрудниками государственного учреждения, её владельцем было государство, и в 1987 году лицензированием игры занялась организация, отвечавшая за международную торговлю электронными технологиями (Электроноргтехника (ELORG)). Аббревиатура V/O на экране юридической информации может быть сокращением от «Version Originale».

Британская компания-разработчик ПО Andromeda попыталась получить права на Tetris и до завершения сделки сублицензировала игру другим поставщикам, например, британскому издателю компьютерных игр Mirrorsoft. Mirrorsoft, в свою очередь, сублицензировал её Tengen, дочерней компании Atari Games. Tengen предоставила Bullet-Proof Software права на разработку игры для компьютеров и консолей в Японии, в результате чего появился Tetris для Nintendo Famicom. Ниже показан его экран юридической информации.


Интересно, что в этой версии школьник Вадим Герасимов назван первоначальным дизайнером и программистом.

Пытаясь получить права на портативную версию для готовящейся консоли Game Boy, Nintendo воспользовалась Bullet-Proof Software для заключения успешной сделки непосредственно с ELORG. В процессе заключения сделки ELORG пересмотрела свой контракт с Andromeda, уточнив, что Andromeda получила права только на игры для компьютеров и аркадных автоматов. Из-за этого Bullet-Proof Software пришлось платить ELORG лицензионные отчисления за все проданные для Famicom картриджи, потому что полученные ею от Tengen права оказались липовыми. Но благодаря примирению с ELORG компании Bullet-Proof Software наконец удалось получить всемирные права на консольные игры для Nintendo.

Bullet-Proof Software сублицензировала права на портативные игры Nintendo и они вместе разработали Game Boy Tetris, что отражено на показанном ниже экране юридической информации.


Получив всемирные права на консольные игры, Nintendo разработала версию Tetris для NES, которую мы изучаем в этой статье. Затем Bullet-Proof Software сублицензировала права у Nintendo, что позволило ей продолжать продажи картриджей для Famicom в Японии.

За этим последовала сложная юридическая тяжба. И Nintendo, и Tengen требовали, чтобы противоложная сторона прекратила производство и продажу своей версии игры. В результате Nintendo победила, и сотни тысяч картриджей Tengen Tetris были уничтожены. Приговор суда также запретил нескольким другим компаниям наподобие Mirrorsoft создавать консольные версии.

Пажитнов так и не получил никаких отчислений от ELORG или советского государства. Однако в 1991 году он переехал в США и в 1996 году при поддержке владельца Bullet-Proof Software Хенка Роджерса стал сооснователем The Tetris Company, позволившей ему получать прибыль от версий для мобильных устройств и современных консолей.

Любопытно взглянуть на экран юридической информации как в окно, дающее представление о скромном зарождении игры и о последовавших за ним боях за права интеллектуальную собственность, ведь для большинства игроков этот экран — просто раздражающая помеха, исчезновения которой, кажется, приходится ждать вечно. Задержка задаётся двумя счётчиками, последовательно выполняющих отсчёт с 255 до 0. Первую фазу пропустить нельзя, а вторая пропускается нажатием кнопки Start. Следовательно, экран юридической информации показывается минимум 4,25 секунды и не больше 8,5 секунды. Однако, я думаю, что большинство игроков сдаётся, прекращая нажимать Start во время первого интервала, и из-за этого ждёт полного завершения.

Тайминг фаз, а также остальная часть игры, регулируется немаскируемым обработчиком прерываний, вызываемым в начале каждого кадрового гасящего импульса (vertical blanking interval) — краткого периода времени между рендерингом телевизионных кадров. То есть каждые 16,6393 миллисекунды нормальное выполнение программы прерывается следующим кодом.

8005: PHA
8006: TXA
8007: PHA
8008: TYA
8009: PHA ; save A, X, Y

800A: LDA #$00
800C: STA $00B3
800E: JSR $804B ; render();

8011: DEC $00C3 ; legalScreenCounter1--;

8013: LDA $00C3
8015: CMP #$FF ; if (legalScreenCounter1 < 0) {
8017: BNE $801B ; legalScreenCounter1 = 0;
8019: INC $00C3 ; }

801B: JSR $AB5E ; initializeOAM();

801E: LDA $00B1
8020: CLC
8021: ADC #$01
8023: STA $00B1
8025: LDA #$00
8027: ADC $00B2
8029: STA $00B2 ; frameCounter++;

802B: LDX #$17
802D: LDY #$02
802F: JSR $AB47 ; randomValue = generateNextPseudorandomNumber(randomValue);

8032: LDA #$00
8034: STA $00FD
8036: STA $2005 ; scrollX = 0;
8039: STA $00FC
803B: STA $2005 ; scrollY = 0;

803E: LDA #$01
8040: STA $0033 ; verticalBlankingInterval = true;

8042: JSR $9D51 ; pollControllerButtons();

8045: PLA
8046: TAY
8047: PLA
8048: TAX
8049: PLA ; restore A, X, Y

804A: RTI ; resume interrupted task


Обработчик начинает с того, что передаёт значения основных регистров в стек и извлекает их после завершения, чтобы не мешать прерванной задаче. Вызов render() обновляет VRAM, преобразуя описание модели памяти в то, что отображается на экране. Далее обработчик снижает значение счётчика экрана юридической информации, если он больше нуля. Вызов initializeOAM() выполняет этап, необходимый оборудованию генерации кадра. Обработчик продолжает работу, выполняя инкремент счётчика кадров — 16-битного значения little endian, хранящегося по адресу $00B1$00B2, которое он использует в разных местах для контролируемого тайминга. После этого генерируется следующее псевдослучайное число; как сказано выше, это происходит вне зависимости от режима по крайней мере раз в кадр. По адресу $8040 задаётся флаг vertical blanking interval, означающий, что только что выполнялся обработчик. Наконец, опрашиваются кнопки контроллера; поведение этой подпрограммы описано ниже, в разделе «Демо».

Флаг verticalBlankingInterval используется подпрограммой, рассмотренной выше. Она продолжается, пока не начнётся выполнение обработчика прерывания.

AA2F: JSR $E000 ; updateAudio();

AA32: LDA #$00
AA34: STA $0033 ; verticalBlankingInterval = false;

AA36: NOP

AA37: LDA $0033
AA39: BEQ $AA37 ; while(!verticalBlankingInterval) { }

AA3B: LDA #$FF
AA3D: LDX #$02
AA3F: LDY #$02
AA41: JSR $AC6A ; fill memory page 2 with all $FF's

AA44: RTS ; return;


Эта блокирующая подпрограмма используется двумя этапами тайминга экрана юридической информации, которые выполняются один за другим.

8236: LDA #$FF
8238: JSR $A459

...

A459: STA $00C3 ; legalScreenCounter1 = 255;

A45B: JSR $AA2F ; do {
A45E: LDA $00C3 ; waitForVerticalBlankingInterval();
A460: BNE $A45B ; } while(legalScreenCounter1 > 0);

A462: RTS ; return;


823B: LDA #$FF
823D: STA $00A8 ; legalScreenCounter2 = 255;

; do {

823F: LDA $00F5 ; if (just pressed Start) {
8241: CMP #$10 ; break;
8243: BEQ $824C ; }

8245: JSR $AA2F ; waitForVerticalBlankingInterval();

8248: DEC $00A8 ; legalScreenCounter2--;
824A: BNE $823F ; } while(legalScreenCounter2 > 0);

824C: INC $00C0 ; gameMode = TITLE_SCREEN;


Скрипт ИИ на Lua обходит эту задержку, присваивая обоим счётчикам значение 0.

Демо


В демо показывается примерно 80 секунд предварительно записанного геймплея. В нём не просто отображается файл видео, а используется тот же движок, что и в игре. При воспроизведении применяются две таблицы. Первая, расположенная по адресу $DF00, содержит следующую последовательность создания тетримино:

T J T S Z J T S Z J S Z L Z J T T S I T O J S Z L Z L I O L Z L I O J T S I T O J

При создании фигура или выбирается случайно, или считывается из таблицы, в зависимости от режима. Переключение происходит по адресу $98EB.

98EB: LDA $00C0
98ED: CMP #$05
98EF: BNE $9903 ; if (gameMode == DEMO) {

98F1: LDX $00D3
98F3: INC $00D3
98F5: LDA $DF00,X ; value = demoTetriminoTypeTable[++demoIndex];

98F8: LSR
98F9: LSR
98FA: LSR
98FB: LSR
98FC: AND #$07
98FE: TAX ; tetriminoType = bits 6,5,4 of value;

98FF: LDA $994E,X
9902: RTS ; return spawnTable[tetriminoType];
; } else {
; pickRandomTetrimino();
; }


Тип тетримино извлекается из битов 6, 5 и 4 каждого байта. Время от времени эта операция даёт нам значение $07 — неправильный тип. Однако таблица создания фигур ($994E), используемая для преобразования типа тетримино в ID ориентации, на самом деле находится между двумя связанными таблицами:

993B: 00 00 00 00 ; T
993F: 01 01 01 01 ; J
9943: 02 02 ; Z
9945: 03 ; O
9946: 04 04 ; S
9948: 05 05 05 05 ; L
994C: 06 06 ; I


994E: 02 ; Td
994F: 07 ; Jd
9950: 08 ; Zh
9951: 0A ; O
9952: 0B ; Sh
9953: 0E ; Ld
9954: 12 ; Ih


9956: 02 02 02 02 ; Td
995A: 07 07 07 07 ; Jd
995E: 08 08 ; Zh
9960: 0A ; O
9961: 0B 0B ; Sh
9963: 0E 0E 0E 0E ; Ld
9967: 12 12 ; Ih


Значение $07 заставляет её выполнить считывание за концом таблицы, в следующей, что даёт Td ($02).

Из-за такого эффекта эта схема может дать нам неограниченную, но воспроизводимую последовательность псевдослучайных ID ориентации создаваемых фигур. Код будет работать, поскольку любой произвольный адрес в изменяющейся последовательности байтов не позволяет определить, где заканчивается таблица. На самом деле последовательность по адресу $DF00 может быть частью чего-то совершенно не связанного с этим, особенно учитывая то, что назначение оставшихся 5 ненулевых битов непонятно, а генерируемая последовательность демонстрирует повторяемость.

Во время инициализации режима демо индекс таблицы ($00D3) обнуляется по адресу $872B.

Во второй таблице демо содержится запись кнопок геймпада, закодированная в парах байтов. Биты первого байта соответствуют кнопкам.

7 6 5 4 3 2 1 0
A B Select Start Вверх Вниз Влево Вправо

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

Таблица занимает адреса $DD00$DEFF и состоит из 256 пар. Доступ к ней осуществляется подпрограммой по адресу $9D5B.

9D5B: LDA $00D0 ; if (recording mode) {
9D5D: CMP #$FF ; goto recording;
9D5F: BEQ $9DB0 ; }

9D61: JSR $AB9D ; pollController();
9D64: LDA $00F5 ; if (start button pressed) {
9D66: CMP #$10 ; goto startButtonPressed;
9D68: BEQ $9DA3 ; }

9D6A: LDA $00CF ; if (repeats == 0) {
9D6C: BEQ $9D73 ; goto finishedMove;
; } else {
9D6E: DEC $00CF ; repeats--;
9D70: JMP $9D9A ; goto moveInProgress;
; }

finishedMove:

9D73: LDX #$00
9D75: LDA ($D1,X)
9D77: STA $00A8 ; buttons = demoButtonsTable[index];

9D79: JSR $9DE8 ; index++;

9D7C: LDA $00CE
9D7E: EOR $00A8
9D80: AND $00A8
9D82: STA $00F5 ; setNewlyPressedButtons(difference between heldButtons and buttons);

9D84: LDA $00A8
9D86: STA $00CE ; heldButtons = buttons;

9D88: LDX #$00
9D8A: LDA ($D1,X)
9D8C: STA $00CF ; repeats = demoButtonsTable[index];

9D8E: JSR $9DE8 ; index++;

9D91: LDA $00D2 ; if (reached end of demo table) {
9D93: CMP #$DF ; return;
9D95: BEQ $9DA2 ; }

9D97: JMP $9D9E ; goto holdButtons;

moveInProgress:

9D9A: LDA #$00
9D9C: STA $00F5 ; clearNewlyPressedButtons();

holdButtons:

9D9E: LDA $00CE
9DA0: STA $00F7 ; setHeldButtons(heldButtons);

9DA2: RTS ; return;

startButtonPressed:

9DA3: LDA #$DD
9DA5: STA $00D2 ; reset index;

9DA7: LDA #$00
9DA9: STA $00B2 ; counter = 0;

9DAB: LDA #$01
9DAD: STA $00C0 ; gameMode = TITLE_SCREEN;

9DAF: RTS ; return;


Так как таблица кнопок демо имеет длину 512 байтов, для доступа к ней требуется двухбайтный индекс. Индекс хранится как little endian по адресам $00D1$00D2. Он инициализируется со значением адреса таблицы $872D, а его инкремент выполняется следующим кодом.

9DE8: LDA $00D1
9DEA: CLC ; increment [$00D1]
9DEB: ADC #$01 ; possibly causing wrap around to 0
9DED: STA $00D1 ; which produces a carry

9DEF: LDA #$00
9DF1: ADC $00D2
9DF3: STA $00D2 ; add carry to [$00D2]

9DF5: RTS ; return


Программисты оставили в коде обработку ввода игрока, что позволяет нам взглянуть на процесс разработки и заменить демо на другую запись. Режим записи демо включается, когда $00D0 присваивается значение $FF. При этом запускается следующий код, предназначенный для записи в таблицу кнопок демо.

recording:

9DB0: JSR $AB9D ; pollController();

9DB3: LDA $00C0 ; if (gameMode != DEMO) {
9DB5: CMP #$05 ; return;
9DB7: BNE $9DE7 ; }

9DB9: LDA $00D0 ; if (not recording mode) {
9DBB: CMP #$FF ; return;
9DBD: BNE $9DE7 ; }

9DBF: LDA $00F7 ; if (getHeldButtons() == heldButtons) {
9DC1: CMP $00CE ; goto buttonsNotChanged;
9DC3: BEQ $9DE4 ; }

9DC5: LDX #$00
9DC7: LDA $00CE
9DC9: STA ($D1,X) ; demoButtonsTable[index] = heldButtons;

9DCB: JSR $9DE8 ; index++;

9DCE: LDA $00CF
9DD0: STA ($D1,X) ; demoButtonsTable[index] = repeats;

9DD2: JSR $9DE8 ; index++;

9DD5: LDA $00D2 ; if (reached end of demo table) {
9DD7: CMP #$DF ; return;
9DD9: BEQ $9DE7 ; }

9DDB: LDA $00F7
9DDD: STA $00CE ; heldButtons = getHeldButtons();

9DDF: LDA #$00
9DE1: STA $00CF ; repeats = 0;

9DE3: RTS ; return;

buttonsNotChanged:

9DE4: INC $00CF ; repeats++;

9DE6: RTS
9DE7: RTS ; return;


Однако таблица хранится в PRG-ROM. Попытка записи в неё не повлияет на сохранённые даныне. Вместо этого каждая операция записи запускает переключение банков, что приводит к показанному ниже глитчевому эффекту.


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

Чтобы обойти это препятствие, я создал lua/RecordDemo.lua, находящийся в zip с исходниками. После переключения в режим записи демо он перенаправляет операции записи в таблицу в консоль Lua. Из неё байты можно скопировать и вставить в ПЗУ.

Для записи собственного демо запустите FCEUX и загрузите ROM-файл Nintendo Tetris (File | Open ROM...). Затем откройте окно Lua Script window (File | Lua | New Lua Script Window...), перейдите к файлу или введите путь. Нажмите кнопку Run, чтобы запустить режим записи демо, а потом щёлкните мышью на окне FCEUX, чтобы переключить фокус на него. Вы сможете управлять фигурами, пока таблица кнопок не будет заполнена. После этого игра автоматически вернётся на экран заставки. Нажмите Stop в окне Lua Script window, чтобы остановить скрипт. Записанные данные появятся в консоли Output Console, как показано на рисунке ниже.


Выделите всё содержимое и скопируйте в буфер обмена (Ctrl+C). Затем запустите Hex Editor (Debug | Hex Editor...). В меню Hex Editor выберите View | ROM File, а затем File | Goto Address. В диалоговом окне Goto введите 5D10 (адрес таблицы кнопок демо в файле ROM) и нажмите Ok. Затем вставьте содержимое буфера обмена (Ctrl+V).


Наконец в меню FCEUX выберите NES | Reset. Если вам удалось повторить все эти этапы, то демо должно замениться вашей собственной версией.

Если хотите сохранить изменения, то в меню Hex Editor выберите File | Save Rom As… и введите название изменённого ROM-файла, а затем нажмите Save.

Похожим образом можно настроить последовательность создаваемых тетримино.

Экран смерти


Как сказано выше, большинство игроков не может справиться со скоростью спуска фигур на уровне 29, что быстро приводит к завершению игры. Поэтому у игроков он стал ассоциироваться с названием «экран смерти». Но с технической точки зрения экран смерти не даёт игроку пройти дальше из-за бага, при котором быстрый спуск на самом деле оказывается не багом, а фичей. Дизайнеры были так добры, что позволили игре продолжаться, пока игрок способен выдержить сверхчеловеческую скорость.

Настоящий экран смерти возникает примерно на 1550 убранных рядах. Проявляется он по-разному. Иногда игра перезагружается. В других случаях экран просто становится чёрным. Обычно игра замирает («фризится») сразу после удаления ряда, как это показано ниже. Таким эффектам часто предшествуют случайные графические артефакты.


Экран смерти — это результат бага в коде, добавляющего очки при удалении рядов. Шестизнаковый счёт хранится как 24-битный упакованный BCD little endian и расположен по адресам $0053$0055. Для выполнения преобразований между количеством очищенных рядов и полученных очков используется таблица; каждая запись в ней является 16-битным упакованным значением BCD little endian.

9CA5: 00 00 ; 0: 0
9CA7: 40 00 ; 1: 40
9CA9: 00 01 ; 2: 100
9CAB: 00 03 ; 3: 300
9CAD: 00 12 ; 4: 1200


После инкремента общего количества рядов, а возможно и уровня, значение в этом списке умножается на номер уровня плюс единица, а результат прибавляется к очкам. Это наглядно продемонстрировано в таблице из буклета руководства Nintendo Tetris:


Как показано ниже, то умножение имитируется циклом, прибавляющим к счёту очки. Он выполняется после блокировки фигуры, даже если ни один ряд не очищается.

9C31: LDA $0044
9C33: STA $00A8
9C35: INC $00A8 ; for(i = 0; i <= level; i++) {

9C37: LDA $0056
9C39: ASL
9C3A: TAX
9C3B: LDA $9CA5,X ; points[0] = pointsTable[2 * completedLines];

9C3E: CLC
9C3F: ADC $0053
9C41: STA $0053 ; score[0] += points[0];

9C43: CMP #$A0
9C45: BCC $9C4E ; if (upper digit of score[0] > 9) {

9C47: CLC
9C48: ADC #$60
9C4A: STA $0053 ; upper digit of score[0] -= 10;
9C4C: INC $0054 ; score[1]++;
; }

9C4E: INX
9C4F: LDA $9CA5,X ; points[1] = pointsTable[2 * completedLines + 1];

9C52: CLC
9C53: ADC $0054
9C55: STA $0054 ; score[1] += points[1];

9C57: AND #$0F
9C59: CMP #$0A
9C5B: BCC $9C64 ; if (lower digit of score[1] > 9) {

9C5D: LDA $0054
9C5F: CLC ; lower digit of score[1] -= 10;
9C60: ADC #$06 ; increment upper digit of score[1];
9C62: STA $0054 ; }

9C64: LDA $0054
9C66: AND #$F0
9C68: CMP #$A0
9C6A: BCC $9C75 ; if (upper digit of score[1] > 9) {

9C6C: LDA $0054
9C6E: CLC
9C6F: ADC #$60
9C71: STA $0054 ; upper digit of score[1] -= 10;
9C73: INC $0055 ; score[2]++;
; }

9C75: LDA $0055
9C77: AND #$0F
9C79: CMP #$0A
9C7B: BCC $9C84 ; if (lower digit of score[2] > 9) {

9C7D: LDA $0055
9C7F: CLC ; lower digit of score[2] -= 10;
9C80: ADC #$06 ; increment upper digit of score[2];
9C82: STA $0055 ; }

9C84: LDA $0055
9C86: AND #$F0
9C88: CMP #$A0
9C8A: BCC $9C94 ; if (upper digit of score[2] > 9) {

9C8C: LDA #$99
9C8E: STA $0053
9C90: STA $0054
9C92: STA $0055 ; max out score to 999999;
; }

9C94: DEC $00A8
9C96: BNE $9C37 ; }


К сожалению, в Ricoh 2A03 отсутствует режим двоично-десятичных чисел процессора 6502; он бы мог очень упростить тело цикла. Вместо этого сложение выполняется ступенчато, с использованием двоичного режима. Любая цифра, превышающая после сложения значение 9, по сути получается вычитанием 10 и инкрементом цифры слева. Например, $07 + $07 = $0E, что преобразуется в $14. Но такая схема защищена не полностью. Возьмём $09 + $09 = $12: проверка неспособна преобразовать результат в $18. Чтобы компенсировать это, ни одна из десятичных цифр в записях таблицы очков не превышает 6. Кроме того, чтобы проверку можно было использовать, последняя цифра всех записей всегда равна 0.

На выполнение этого долгого и сложного цикла требуется время. На больших уровнях большое количество итераций влияет на тайминг игры, потому что на генерацию каждого кадра уходит больше 1/60 секунды. Всё это в результате приводит к различным проявлениям «экрана смерти».

Скрипт ИИ на Lua ограничивает количество итераций в цикле значением 30 — максимальной величиной, которой по замыслу дизайнеров могли достигнуть игроки, что позволяет устранить экран смерти.

Концовки


В буклете руководства Nintendo Tetris игра в режиме A-Type описывается так:


Игра награждает игроков, набравших достаточно большое количеств очков одной из пяти анимаций концовок. Выбор концовки целиком основан на двух самых левых цифрах шестиразрядного счёта. Как показано ниже, чтобы получить одну из концовок, игрок должен набрать не меньше 30 000 очков.

9A4D: LDA $0075
9A4F: CMP #$03
9A51: BCC $9A5E ; if (score[2] >= $03) {

9A53: LDA #$80
9A55: JSR $A459
9A58: JSR $9E3A
9A5B: JMP $9A64 ; select ending;
; }


Стоит заметить, что $0060$007F — это зеркало адресов $0040$005F. Счёт дублируется по адресам $0073$0075.

После прохождения первой проверки анимация концовки выбирается следующим оператором switch.

A96E: LDA #$00
A970: STA $00C4
A972: LDA $0075 ; if (score[2] < $05) {
A974: CMP #$05 ; ending = 0;
A976: BCC $A9A5 ; }

A978: LDA #$01
A97A: STA $00C4
A97C: LDA $0075 ; else if (score[2] < $07) {
A97E: CMP #$07 ; ending = 1;
A980: BCC $A9A5 ; }

A982: LDA #$02
A984: STA $00C4
A986: LDA $0075 ; else if (score[2] < $10) {
A988: CMP #$10 ; ending = 2;
A98A: BCC $A9A5 ; }

A98C: LDA #$03
A98E: STA $00C4
A990: LDA $0075 ; else if (score[2] < $12) {
A992: CMP #$12 ; ending = 3;
A994: BCC $A9A5 ; }

A996: LDA #$04 ; else {
A998: STA $00C4 ; ending = 4;
; }


В концовках ракеты увеличивающегося размера запускаются со стартовой площадки рядом с собором Василия Блаженного. В четвёртой концовке показывается космический корабль «Буран» — советская версия американского «Спейс шаттла». В самой лучшей концовке в воздух поднимается сам собор, а над стартовой площадкой висит НЛО. Ниже показано изображение каждой концовки и связанный с ней счёт.
30000–49999
50000–69999
70000–99999
100000–119999
120000+

В режиме игры B-Type реализовано другое испытание, которое в буклете руководства Nintendo Tetris описывается так:


Если игрок успешно очищает 25 ряда, то игра показывает концовку, зависящую от начального уровня. Концовки для уровней 0–8 состоят из животных и объектов, летающих или бегающих в кадре, загадочным образом проходя за собором Василия Блаженного. НЛО из лучшей концовки режима A-Type появляется в концовке 3. В концовке 4 появляются вымершие летающие птерозавры, а в концовке 7 показаны мифические летающие драконы. В концовках 2 и 6 показаны бескрылые птицы: бегающие пингвины и страусы. В концовке 5 небо заполнено дирижаблями GOOD (не путать с дирижаблями Goodyear). А в концовке 8 по экрану проносится множество «Буранов», хотя на самом деле он был всего один.

Начальная высота (плюс 1) используется как множитель, вознаграждая игрока бОльшим количеством животных/объектов за повышенную сложность.

В лучшей концовке B-Type показан замок, заполненный персонажами из вселенной Nintendo: принцесса Пич хлопает в ладоши, Kid Icarus играет на скрипке, Донки Конг стучит в большой барабан, Марио и Луиджи танцуют, Баузер играет на аккордеоне, Самус играет на виолончели, Линк — на флейте, в то время как купола собора Василия Блаженного взмывают в воздух. От начальной высоты зависит количество этих элементов показываемых в концовке. Ниже показаны изображения всех 10 концовок.


ИИ может быстро очистить все 25 рядов, необходимых в режиме B-Type при любом начальном уровне и высоте, что позволяет посмотреть любую из концовок. Стоит также оценить, насколько здорово он обращается с большими кучами случайных блоков.

В концовках 0–8 в кадре могут двигаться до 6 объектов. Координаты y объектов хранятся в таблице, расположенной в по адресу $A7B7.

A7B7: 98 A8 C0 A8 90 B0 ; 0
A7BD: B0 B8 A0 B8 A8 A0 ; 1
A7C3: C8 C8 C8 C8 C8 C8 ; 2
A7C9: 30 20 40 28 A0 80 ; 3
A7CF: A8 88 68 A8 48 78 ; 4
A7D5: 58 68 18 48 78 38 ; 5
A7DB: C8 C8 C8 C8 C8 C8 ; 6
A7E1: 90 58 70 A8 40 38 ; 7
A7E7: 68 88 78 18 48 A8 ; 8


Горизонтальные расстояния между объектами хранятся в таблице по адресу $A77B.

A77B: 3A 24 0A 4A 3A FF ; 0
A781: 22 44 12 32 4A FF ; 1
A787: AE 6E 8E 6E 1E 02 ; 2
A78D: 42 42 42 42 42 02 ; 3
A793: 22 0A 1A 04 0A FF ; 4
A799: EE DE FC FC F6 02 ; 5
A79F: 80 80 80 80 80 FF ; 6
A7A5: E8 E8 E8 E8 48 FF ; 7
A7AB: 80 AE 9E 90 80 02 ; 8


Последовательность значений со знаком по адресу $A771 определяет скорость и направление объектов.

A771: 01 ; 0: 1
A772: 01 ; 1: 1
A773: FF ; 2: -1
A774: FC ; 3: -4
A775: 01 ; 4: 1
A776: FF ; 5: -1
A777: 02 ; 6: 2
A778: 02 ; 7: 2
A779: FE ; 8: -1


Индексы спрайтов хранятся по адресу $A7F3.

A7F3: 2C ; 0: dragonfly
A7F4: 2E ; 1: dove
A7F5: 54 ; 2: penguin
A7F6: 32 ; 3: UFO
A7F7: 34 ; 4: pterosaur
A7F8: 36 ; 5: blimp
A7F9: 4B ; 6: ostrich
A7FA: 38 ; 7: dragon
A7FB: 3A ; 8: Buran


На самом деле каждый объект состоит из двух спрайтов с соседними индексами. Для получения второго индекса нужно прибавить 1. Например, дракон состоит из $38 и $39. Тайлы для этих спрайтов содержатся в показанных ниже таблицах паттернов.


Центральную таблицу паттерна мы рассматривали выше, она используется для отображения тетримино и игрового поля. Интересно, что она содержит весь алфавит, в то время как в других содержится только его часть для экономии места. Но ещё более интересны спрайты самолёта и вертолёта в таблице паттерна слева; они не появляются ни в концовках, ни в других частях игры. Выяснилось, что самолёт и вертолёт имеют индексы спрайтов $30 и $16 и можно изменить показанную выше таблицу, чтобы увидеть их в действии.



К сожалению, опоры вертолёта не отображаются, но основной и хвостовой роторы красиво анимированы.

2 Player Versus


В Nintendo Tetris содержится незавершённый режим на двух игроков, который можно включить, изменив количество игроков ($00BE) на 2. Как показано ниже, на фоне однопользовательского режима появляется два игровых поля.


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

Но если не учитывать эти проблемы, то режим вполне играбелен. Каждый игрок может независимо управлять фигурами в соответствующем игровом поле. А когда игрок набирает Double, Triple или Tetris (то есть очищает два, три или четыре ряда), в нижней части игрового поля противника появляются мусорные ряды с одним отсутствующим квадратом.

Дополнительное поле расположено по адресу $0500. А $0060$007F, обычно являющиеся зеркалом $0040$005F, используются для второго игрока.

Вероятно, от этого интересного режима отказались из-за плотного графика разработки. А возможно его оставили незавершённым намеренно. Одна из причин, по которой Tetris был выбран в качестве поставляемой в комплекте с Nintendo Game Boy игры, заключалась в том, что он стимулировал к покупке Game Link Cable — аксессуара, соединявшего вместе два Game Boy для запуска режима 2 player versus. Этот кабель добавлял в систему элемент «социальности» — подталкивал друзей покупать Game Boy, чтобы присоединиться к веселью. Возможно, Nintendo опасалась, что если в консольной версии игры был бы режим 2 player versus, то «рекламная» мощь Tetris, стимулировавшая к покупке Game Boy, могла оказаться ослабленной.

Музыка и звуковые эффекты


Фоновая музыка включается, когда $06F5 присваивается одно из перечисленных в таблице значений.

Значение Описание
01 Неиспользованная музыка экрана заставки
02 Достигнута цель режима B-Type
03 Music-1
04 Music-2
05 Music-3
06 Music-1 allegro
07 Music-2 allegro
08 Music-3 allegro
09 Экран Congratulations
0A Концовки
0B Достигнута цель режима B-Type
Неиспользованную музыку экрана заставки можно послушать здесь. В самой игре во время экрана заставки ничего не звучит.

Music-1 — это версия "Танца Феи Драже", музыки для балерины из третьего действия па-де-де вальса «Щелкунчик» Чайковского. Музыка концовки — это вариация "Куплетов тореадора", арии из оперы Кармен Жоржа Бизе. Эти композиции саранжированы композитором всей остальной музыки игры Хирокадзу Танака.

Music-2 вдохновлялась традиционными фольклорными русскими песнями. Music-3 загадочна, футуристична и нежна; какое-то время она была мелодией ожидания телефона службы поддержки клиентов Nintendo of America.

Чтобы помочь игроку впасть в состояние паники, когда высота кучи приближается к потолку игрового поля, начинает воспроизводиться версия фоновой музыки в быстром темпе($06$08).

Интересно, что среди музыкальных композиций нет "Коробейников", знаменитой темы, звучащей в Game Boy Tetris.

Звуковые эффекты инициируются записью в $06F0 и $06F1, в соответствии со следующей таблицей.

Адрес Значение Описание
06F0 02 Занавес конца игры
06F0 03 Ракета в концовке
06F1 01 Выбор опции меню
06F1 02 Выбор экрана меню
06F1 03 Сдвиг тетримино
06F1 04 Получен Tetris
06F1 05 Поворот тетримино
06F1 06 Новый уровень
06F1 07 Блокировка тетримино
06F1 08 Щебетание
06F1 09 Очистка ряда
06F1 0A Ряд заполнен

Игровые состояния и режимы рендеринга


Во время игрового процесса текущее состояние игры представлено целочисленным числом по адресу $0048. БОльшую часть времени оно имеет значение $01, обозначающее, что игрок управляет активным тетримино. Однако когда фигура блокируется на месте, игра поэтапно проходит от состояния $02 до состояния $08, как это показано в таблице.

Состояние Описание
00 Неназначенный ID ориентации
01 Игрок управляет активным тетримино
02 Блокировка тетримино на игровом поле
03 Проверка заполненных строк
04 Отображение анимации очистки ряда
05 Обновление строк и статистики
06 Проверка цели режима B-Type
07 Не используется
08 Создать следующее тетримино
09 Не используется
0A Обновление занавеса конца игры
0B Инкремент игрового состояния

Ветвление кода в зависимости от игрового состояния происходит по адресу $81B2:

81B2: LDA $0048
81B4: JSR $AC82 ; switch(playState) {
81B7: 2F 9E ; case 00: goto 9E2F; // Unassign orientationID
81B9: CF 81 ; case 01: goto 81CF; // Player controls active Tetrimino
81BB: A2 99 ; case 02: goto 99A2; // Lock Tetrimino into playfield
81BD: 6B 9A ; case 03: goto 9A6B; // Check for completed rows
81BF: 39 9E ; case 04: goto 9E39; // Display line clearing animation
81C1: 58 9B ; case 05: goto 9B58; // Update lines and statistics
81C3: F2 A3 ; case 06: goto A3F2; // B-Type goal check; Unused frame for A-Type
81C5: 03 9B ; case 07: goto 9B03; // Unused frame; Execute unfinished 2 player mode logic
81C7: 8E 98 ; case 08: goto 988E; // Spawn next Tetrimino
81C9: 39 9E ; case 09: goto 9E39; // Unused
81CB: 11 9A ; case 0A: goto 9A11; // Update game over curtain
81CD: 37 9E ; case 0B: goto 9E37; // Increment play state
; }


При состоянии $00 switch выполняет переход к коду, присваивающему orientationID значение $13, обозначающее, что ориентация не задана.

9E2F: LDA #$13
9E31: STA $0042 ; orientationID = UNASSIGNED;

9E33: RTS ; return;


Обработчик никогда не вызывается; однако игровое состояние $00 служит сигналом для других частей кода.

Состояние $01 позволяет игроку сдвигать, поворачивать и опускать вниз активное тетримино:

81CF: JSR $89AE ; shift Tetrimino;
81D2: JSR $88AB ; rotate Tetrimino;
81D5: JSR $8914 ; drop Tetrimino;

81D8: RTS ; return;


Как сказано в предыдущих разделах, подпрограммы сдвига, поворота и спуска фигуры перед выполнением кода проверяют новые позиции тетримино. Единственный способ блокировать фигуру в неправильной позиции — создать её поверх уже имеющейся фигуры. При этом игра заканчивается. Как показано ниже, эту проверку выполняет код состояния $02.

99A2: JSR $948B ; if (new position valid) {
99A5: BEQ $99B8 ; goto updatePlayfield;
; }

99A7: LDA #$02
99A9: STA $06F0 ; play curtain sound effect;

99AC: LDA #$0A
99AE: STA $0048 ; playState = UPDATE_GAME_OVER_CURTAIN;

99B0: LDA #$F0
99B2: STA $0058 ; curtainRow = -16;

99B4: JSR $E003 ; updateAudio();

99B7: RTS ; return;


Если заблокированная позиция правильна, она помечает 4 связанные с ней ячейки игрового поля как занятые. В противном случае она выполняет переход к состоянию $0A — зловещему занавесу конца игры.


Занавес отрисовывается с верха игрового поля вниз, спускаясь на одну строку через каждые 4 кадра. curtainRow ($0058) инициализируется со значением −16, создавая дополнительную задержку в 0,27 секунды между окончательной блокировкой и началом анимации. По адресу $9A21 в состоянии $0A показанного ниже кода выполняется доступ к таблице умножения, котороая ошибочно отображается как номера уровней. Это делается для масштабирования curtainRow на 10. Кроме того, как показано выше, код по адресу $9A51 запускает анимацию концовки, если счёт игрока не меньше 30 000 очков; в противном случае он ожидает нажатия Start.

9A11: LDA $0058 ; if (curtainRow == 20) {
9A13: CMP #$14 ; goto endGame;
9A15: BEQ $9A47 ; }

9A17: LDA $00B1 ; if (frameCounter not divisible by 4) {
9A19: AND #$03 ; return;
9A1B: BNE $9A46 ; }

9A1D: LDX $0058 ; if (curtainRow < 0) {
9A1F: BMI $9A3E ; goto incrementCurtainRow;
; }

9A21: LDA $96D6,X
9A24: TAY ; rowIndex = 10 * curtainRow;

9A25: LDA #$00
9A27: STA $00AA ; i = 0;

9A29: LDA #$13
9A2B: STA $0042 ; orientationID = NONE;

drawCurtainRow:

9A2D: LDA #$4F
9A2F: STA ($B8),Y ; playfield[rowIndex + i] = CURTAIN_TILE;
9A31: INY
9A32: INC $00AA ; i++;
9A34: LDA $00AA
9A36: CMP #$0A ; if (i != 10) {
9A38: BNE $9A2D ; goto drawCurtainRow;
; }

9A3A: LDA $0058
9A3C: STA $0049 ; vramRow = curtainRow;

incrementCurtainRow:

9A3E: INC $0058 ; curtainRow++;

9A40: LDA $0058 ; if (curtainRow != 20) {
9A42: CMP #$14 ; return;
9A44: BNE $9A46 ; }

9A46: RTS ; return;

endGame:

9A47: LDA $00BE
9A49: CMP #$02
9A4B: BEQ $9A64 ; if (numberOfPlayers == 1) {

9A4D: LDA $0075
9A4F: CMP #$03
9A51: BCC $9A5E ; if (score[2] >= $03) {

9A53: LDA #$80
9A55: JSR $A459
9A58: JSR $9E3A
9A5B: JMP $9A64 ; select ending;
; }

9A5E: LDA $00F5 ; if (not just pressed Start) {
9A60: CMP #$10 ; return;
9A62: BNE $9A6A ; }
; }

9A64: LDA #$00
9A66: STA $0048 ; playState = INITIALIZE_ORIENTATION_ID;
9A68: STA $00F5 ; clear newly pressed buttons;

9A6A: RTS ; return;


Код завершается присвоением игровому состоянию значения $00, но соответствующий обработчик не вызывается, потому что игра завершена.

Строки игрового поля инкрементно копируются во VRAM для их отображения. Индекс текущей копируемой строки содержится в vramRow ($0049). По адресу $9A3C vramRow присваивается значение curtainRow, что в конце концов делает эту строку видимой при рендеринге.

Манипуляции с VRAM происходят во время кадрового гасящего импульса (vertical blanking interval), который распознаётся обработчиком прерываний, описанным в разделе «Экран с юридической информацией». Он вызывает показанную ниже подпрограмму (помеченную в комментариях обработчика прерываний как render()).

804B: LDA $00BD
804D: JSR $AC82 ; switch(renderMode) {
8050: B1 82 ; case 0: goto 82B1; // Legal and title screens
8052: DA 85 ; case 1: goto 85DA; // Menu screens
8054: 44 A3 ; case 2: goto A344; // Congratulations screen
8056: EE 94 ; case 3: goto 94EE; // Play and demo
8058: 95 9F ; case 4: goto 9F95; // Ending animation
; }


Режим рендеринга схож с игровым режимом. Он хранится по адресу $00BD и может иметь одно из следующих значений:

Значение Описание
00 Экран с юр. информацией и экран заставки
01 Экраны меню
02 Экран Congratulations
03 Игра и демо
04 Анимация концовки

Часть режима рендеринга $03 показана ниже.

952A: JSR $9725 ; copyPlayfieldRowToVRAM();
952D: JSR $9725 ; copyPlayfieldRowToVRAM();
9530: JSR $9725 ; copyPlayfieldRowToVRAM();
9533: JSR $9725 ; copyPlayfieldRowToVRAM();


Как видно ниже, copyPlayfieldRowToVRAM() передаёт во VRAM строку игрового поля, имеющую индекс vramRow. Если vramRow больше 20, подпрограмма ничего не делает.

9725: LDX $0049 ; if (vramRow > 20) {
9727: CPX #$15 ; return;
9729: BPL $977E ; }

972B: LDA $96D6,X
972E: TAY ; playfieldAddress = 10 * vramRow;

972F: TXA
9730: ASL
9731: TAX
9732: INX ; high = vramPlayfieldRows[vramRow * 2 + 1];
9733: LDA $96EA,X
9736: STA $2006
9739: DEX

973A: LDA $00BE
973C: CMP #$01
973E: BEQ $975E ; if (numberOfPlayers == 2) {

9740: LDA $00B9
9742: CMP #$05
9744: BEQ $9752 ; if (leftPlayfield) {

9746: LDA $96EA,X
9749: SEC
974A: SBC #$02
974C: STA $2006 ; low = vramPlayfieldRows[vramRow * 2] - 2;

974F: JMP $9767 ; } else {

9752: LDA $96EA,X
9755: CLC
9756: ADC #$0C
9758: STA $2006 ; low = vramPlayfieldRows[vramRow * 2] + 12;

975B: JMP $9767 ; } else {

975E: LDA $96EA,X
9761: CLC
9762: ADC #$06 ; low = vramPlayfieldRows[vramRow * 2] + 6;
9764: STA $2006 ; }

; vramAddress = (high << 8) | low;

9767: LDX #$0A
9769: LDA ($B8),Y
976B: STA $2007
976E: INY ; for(i = 0; i < 10; i++) {
976F: DEX ; vram[vramAddress + i] = playfield[playfieldAddress + i];
9770: BNE $9769 ; }

9772: INC $0049 ; vramRow++;
9774: LDA $0049 ; if (vramRow < 20) {
9776: CMP #$14 ; return;
9778: BMI $977E ; }

977A: LDA #$20
977C: STA $0049 ; vramRow = 32;

977E: RTS ; return;


Таблица vramPlayfieldRows ($96EA) содержит адреса VRAM в формате little endian, соответствующие отображаемым строкам игрового поля, сдвинутым на 6 в обычном режиме и на −2 и 12 для игрового поля в недоделанном режиме 2 Player Versus. Байты этой таблицы являются частью списка значений, ошибочно отображаемых как номера уровней после уровня 29. Соседние нижние и верхние байты каждого адреса получаются по отдельности и по сути комбинируются в 16-битный адрес, который используется в цикле копирования.

В конце подпрограммы выполняется инкремент vramRow. Если значение достигает 20, то ему присваивается значение 32, означающее, что копирование полностью завершено. Как показано выше, за кадр копируется только 4 строки.

Обработчик состояния $03 отвечает за распознавание завершённых строк и удаление их с игрового поля. На протяжении 4 отдельных вызовов он сканирует смещения строк [−2, 1] рядом с центром тетримино (обе координаты всех квадратов тетримино находятся в этом интервале). Индексы завершённых строк хранятся по адресу $004A$004D; записанный индекс 0 используется для обозначения того, что в этом проходе не найдено ни одной завершённой строки. Обработчик показан ниже.

9A6B: LDA $0049
9A6D: CMP #$20 ; if (vramRow < 32) {
9A6F: BPL $9A74 ; return;
9A71: JMP $9B02 ; }

9A74: LDA $0041 ; rowY = tetriminoY - 2;
9A76: SEC
9A77: SBC #$02 ; if (rowY < 0) {
9A79: BPL $9A7D ; rowY = 0;
9A7B: LDA #$00 ; }

9A7D: CLC
9A7E: ADC $0057
9A80: STA $00A9 ; rowY += lineIndex;

9A82: ASL
9A83: STA $00A8
9A85: ASL
9A86: ASL
9A87: CLC
9A88: ADC $00A8
9A8A: STA $00A8 ; rowIndex = 10 * rowY;

9A8C: TAY
9A8D: LDX #$0A
9A8F: LDA ($B8),Y
9A91: CMP #$EF ; for(i = 0; i < 10; i++) {
9A93: BEQ $9ACC ; if (playfield[rowIndex + i] == EMPTY_TILE) {
9A95: INY ; goto rowNotComplete;
9A96: DEX ; }
9A97: BNE $9A8F ; }

9A99: LDA #$0A
9A9B: STA $06F1 ; play row completed sound effect;

9A9E: INC $0056 ; completedLines++;

9AA0: LDX $0057
9AA2: LDA $00A9
9AA4: STA $4A,X ; lines[lineIndex] = rowY;

9AA6: LDY $00A8
9AA8: DEY
9AA9: LDA ($B8),Y
9AAB: LDX #$0A
9AAD: STX $00B8
9AAF: STA ($B8),Y
9AB1: LDA #$00
9AB3: STA $00B8
9AB5: DEY ; for(i = rowIndex - 1; i >= 0; i--) {
9AB6: CPY #$FF ; playfield[i + 10] = playfield[i];
9AB8: BNE $9AA9 ; }

9ABA: LDA #$EF
9ABC: LDY #$00
9ABE: STA ($B8),Y
9AC0: INY ; for(i = 0; i < 10; i++) {
9AC1: CPY #$0A ; playfield[i] = EMPTY_TILE;
9AC3: BNE $9ABE ; }

9AC5: LDA #$13
9AC7: STA $0042 ; orientationID = UNASSIGNED;

9AC9: JMP $9AD2 ; goto incrementLineIndex;

rowNotComplete:

9ACC: LDX $0057
9ACE: LDA #$00
9AD0: STA $4A,X ; lines[lineIndex] = 0;

incrementLineIndex:

9AD2: INC $0057 ; lineIndex++;

9AD4: LDA $0057 ; if (lineIndex < 4) {
9AD6: CMP #$04 ; return;
9AD8: BMI $9B02 ; }

9ADA: LDY $0056
9ADC: LDA $9B53,Y
9ADF: CLC
9AE0: ADC $00BC
9AE2: STA $00BC ; totalGarbage += garbageLines[completedLines];

9AE4: LDA #$00
9AE6: STA $0049 ; vramRow = 0;
9AE8: STA $0052 ; clearColumnIndex = 0;

9AEA: LDA $0056
9AEC: CMP #$04
9AEE: BNE $9AF5 ; if (completedLines == 4) {
9AF0: LDA #$04 ; play Tetris sound effect;
9AF2: STA $06F1 ; }

9AF5: INC $0048 ; if (completedLines > 0) {
9AF7: LDA $0056 ; playState = DISPLAY_LINE_CLEARING_ANIMATION;
9AF9: BNE $9B02 ; return;
; }

9AFB: INC $0048 ; playState = UPDATE_LINES_AND_STATISTICS;

9AFD: LDA #$07
9AFF: STA $06F1 ; play piece locked sound effect;

9B02: RTS ; return;


Проверка vramRow в начале не позволяет обработчику выполняться при переносе строк игрового поля в VRAM (обработчик состояния $03 вызывается в каждом кадре). Если обнаружены заполненные ряды, vramRow сбрасывается на 0, что заставляет выполнить полный перенос.

lineIndex ($00A9) инициализируется со значением 0 и его инкремент выполняется в каждом проходе.

В отличие от игрового состояния $0A и подпрограммы копирования игрового поля, которые используют таблицу умножения по адресу $96D6, блок, начинающийся с $9A82 умножает rowY на 10 с помощью сдвигов и сложения:

rowIndex = (rowY << 1) + (rowY << 3); // rowIndex = 2 * rowY + 8 * rowY;

Так делается только потому, что rowY ограничен интервалом [0, 20], а таблица умножения покрывает только [0, 19]. Сканирование рядов может выйти за конец игрового поля. Однако, как сказано ранее, игра инициализирует $0400$04FF со значением $EF (пустого тайла), создавая более 5 дополнительных пустых скрытых строк под полом игрового поля.

Блок, начинающийся с $9ADA, является частью незавершённого режима 2 Player Versus. Как сказано выше, очистка рядов добавляет на игровое поле противника мусор. Количество мусорных рядов определяется таблицей по адресу $9B53:

9B53: 00 ; no cleared lines
9B54: 00 ; Single
9B55: 01 ; Double
9B56: 02 ; Triple
9B57: 04 ; Tetris


Цикл по адресу $9AA6 сдвигает материал над заполненным рядом на одну строку вниз. Он пользуется тем, что каждая строка в непрерывной последовательности отделена от другой 10 байтами. Следующий за этим цикл очищает верхнюю строку.

Анимация очистки ряда выполняется во время игрового состояния $04, но как показано ниже, она не происходит в обработчике игрового состояния, который полностью пуст.

9E39: RTS ; return;

Вместо этого во время игрового состояния $04 выполняется следующее ветвление режима рендеринга $03.

94EE: LDA $0068
94F0: CMP #$04
94F2: BNE $9522 ; if (playState == DISPLAY_LINE_CLEARING_ANIMATION) {

94F4: LDA #$04
94F6: STA $00B9 ; leftPlayfield = true;

94F8: LDA $0072
94FA: STA $0052
94FC: LDA $006A
94FE: STA $004A
9500: LDA $006B
9502: STA $004B
9504: LDA $006C
9506: STA $004C
9508: LDA $006D
950A: STA $004D
950C: LDA $0068
950E: STA $0048 ; mirror values;

9510: JSR $977F ; updateLineClearingAnimation();

; ...
; }


leftPlayfield и отзеркаленные значения нужны для недоделанного режима 2 Player Versus.

Ниже показана подпрограмма updateLineClearingAnimation(). Она вызывается в каждом кадре, но условие в начале позволяет выполнять её только в каждом четвёртом кадре. В каждом проходе она циклически обходит список индексов завершённых строк и очищает 2 столбца в этих строках, двигаясь от центрального столбца наружу.

977F: LDA $00B1 ; if (frameCounter not divisible by 4) {
9781: AND #$03 ; return;
9783: BNE $97FD ; }

9785: LDA #$00 ; for(i = 0; i < 4; i++) {
9787: STA $00AA ; rowY = lines[i];
9789: LDX $00AA ; if (rowY == 0) {
978B: LDA $4A,X ; continue;
978D: BEQ $97EB ; }

978F: ASL
9790: TAY
9791: LDA $96EA,Y
9794: STA $00A8 ; low = vramPlayfieldRows[2 * rowY];

9796: LDA $00BE ; if (numberOfPlayers == 2) {
9798: CMP #$01 ; goto twoPlayers;
979A: BNE $97A6 ; }

979C: LDA $00A8
979E: CLC
979F: ADC #$06
97A1: STA $00A8 ; low += 6;

97A3: JMP $97BD ; goto updateVRAM;

twoPlayers:

97A6: LDA $00B9
97A8: CMP #$04
97AA: BNE $97B6 ; if (leftPlayfield) {

97AC: LDA $00A8
97AE: SEC
97AF: SBC #$02
97B1: STA $00A8 ; low -= 2;

97B3: JMP $97BD ; } else {

97B6: LDA $00A8
97B8: CLC
97B9: ADC #$0C ; low += 12;
97BB: STA $00A8 ; }

updateVRAM:

97BD: INY
97BE: LDA $96EA,Y
97C1: STA $00A9
97C3: STA $2006
97C6: LDX $0052 ; high = vramPlayfieldRows[2 * rowY + 1];
97C8: LDA $97FE,X
97CB: CLC ; rowAddress = (high << 8) | low;
97CC: ADC $00A8
97CE: STA $2006 ; vramAddress = rowAddress + leftColumns[clearColumnIndex];
97D1: LDA #$FF
97D3: STA $2007 ; vram[vramAddress] = 255;

97D6: LDA $00A9
97D8: STA $2006
97DB: LDX $0052 ; high = vramPlayfieldRows[2 * rowY + 1];
97DD: LDA $9803,X
97E0: CLC ; rowAddress = (high << 8) | low;
97E1: ADC $00A8
97E3: STA $2006 ; vramAddress = rowAddress + rightColumns[clearColumnIndex];
97E6: LDA #$FF
97E8: STA $2007 ; vram[vramAddress] = 255;

97EB: INC $00AA
97ED: LDA $00AA
97EF: CMP #$04
97F1: BNE $9789 ; }

97F3: INC $0052 ; clearColumnIndex++;
97F5: LDA $0052 ; if (clearColumnIndex < 5) {
97F7: CMP #$05 ; return;
97F9: BMI $97FD ; }

97FB: INC $0048 ; playState = UPDATE_LINES_AND_STATISTICS;

97FD: RTS ; return;


16-битный адрес VRAM составляется таким же образом, который показан в подпрограмме копирования игрового поля. Однако в этом случае он выполняет смещение на индекс столбца, полученный из показанной ниже таблицы.

97FE: 04 03 02 01 00 ; left columns
9803: 05 06 07 08 09 ; right columns


Для анимации очистки требуется 5 проходов. Затем код переходит к следующему игровому состоянию.

Обработчик игрового состояния $05 содержит код, описанный в разделе «Ряды и статистика». Обработчик заканчивается этим кодом:

9C9E: LDA #$00
9CA0: STA $0056 ; completedLines = 0;

9CA2: INC $0048 ; playState = B_TYPE_GOAL_CHECK;

9CA4: RTS ; return;


Переменная completedLines не сбрасывается до завершения игрового состояния $05, после чего она используется для обновления общего количества рядов и счёта. Эта последовательность допускает выполнение интересного бага. В режиме демо нужно дождаться, пока игра не соберёт полный ряд, а затем быстро нажать Start, пока не закончилась анимация очистки ряда. Игра вернётся к экрану заставки, но если вы правильно подберёте время, значение completedLines сохранится. Теперь можно начать игру в режиме A-Type. При блокировке на месте первой фигуры обработчик игрового состояния $03 начнёт сканирование завершённых рядов. Он не найдёт их, но оставит completedLines неизменным. Наконец, при выполнении игрового состояния $05 общее количество рядов и счёт увеличатся, как будто их набрали вы.

Проще всего это сделать и получить наибольшее количество, дождавшись, пока демо соберёт Tetris (в демо их будет 2). Как только вы увидите мерцание экрана, нажимайте Start.


После запуска новой игры экран продолжит мерцать. Всё это благодаря следующему коду, вызываемому обработчиком прерываний.

9673: LDA #$3F
9675: STA $2006
9678: LDA #$0E
967A: STA $2006 ; prepare to modify background tile color;

967D: LDX #$00 ; color = DARK_GRAY;

967F: LDA $0056
9681: CMP #$04
9683: BNE $9698 ; if (completedLines == 4) {

9685: LDA $00B1
9687: AND #$03
9689: BNE $9698 ; if (frameCounter divisible by 4) {

968B: LDX #$30 ; color = WHITE;

968D: LDA $00B1
968F: AND #$07
9691: BNE $9698 ; if (frameCounter divisible by 8) {

9693: LDA #$09
9695: STA $06F1 ; play clear sound effect;

; }
; }
; }

9698: STX $2007 ; update background tile color;


На самом деле, если вы позволите первой фигуре автоматически спуститься до пола игрового поля, счёт увеличится на ещё большее значение, потому что holdDownPoints ($004F) тоже сохранит своё значение из демо. Это справедливо даже для случаев, когда демо не заполнило ни одного ряда. holdDownPoints не сбрасывается, пока не нажата кнопка «Вниз».

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

Игровое состояние $06 выполняет проверку цели для игр режима B-Type. В режиме A-Type оно по сути является неиспользуемым кадром.

Игровое состояние $07 содержит исключительно логику незавершённого режима 2 Player Versus. В режиме одного игрока оно ведёт себя как неиспользованный кадр.

Игровое состояние $08 рассмотрено в разделах «Создание тетримино» и «Выбор тетримино».

Игровое состояние $09 не используется. $0B увеличивает игровое состояние, но тоже выглядит неиспользуемым.

И вот, наконец, основной цикл игры:

; while(true) {

8138: JSR $8161 ; branchOnGameMode();

813B: CMP $00A7 ; if (vertical blanking interval wait requested) {
813D: BNE $8142 ; waitForVerticalBlankingInterval();
813F: JSR $AA2F ; }

8142: LDA $00C0
8144: CMP #$05
8146: BNE $815A ; if (gameMode == DEMO) {

8148: LDA $00D2
814A: CMP #$DF
814C: BNE $815A ; if (reached end of demo table) {

814E: LDA #$DD
8150: STA $00D2 ; reset demo table index;

8152: LDA #$00
8154: STA $00B2 ; clear upper byte of frame counter;

8156: LDA #$01
8158: STA $00C0 ; gameMode = TITLE_SCREEN;
; }
; }
815A: JMP $8138 ; }
  • +36
  • 7,2k
  • 9
Поддержать автора
Поделиться публикацией

Комментарии 9

    0
    Спасибо, интересно! Любопытно будет почитать вторую часть про алгоритмы бота, который играет в тетрис, потому что в строгом смысле эта задача NP-полная. Подозреваю, что там будет просто разумное приближение с допущениями, и ИИ всё-таки будет в конце концов проигрывать.
    Кстати, жалко, что статья об этом NES-тетрисе, а не от Tengen. Лично мне тот сильно больше нравится.
      0
      Хотя, кстати, запамятовал: NP-полная задача — задача какой-либо оптимизации стратегии с известной заранее лентой фигур. И разумное приближение как раз, наверное, будет в том, что состояние стакана в итерации будет фиксировано, а известны будут только текущая и следующая фигуры — т.е. естественные ограничения игры. А все такие комбинации расположения двух фигур можно, думаю, и просто перебрать. Но, всё-таки, рано или поздно ИИ всё равно должен проиграть.
      arxiv.org/pdf/cs/0210020.pdf
        0
        Там как и во многих других местах — простое соседствует с безумно сложным.

        Просто — доказать, что если фигурки генерируются случайно, то независимо от действий игрока он рано или поздно проигрывает.

        Дальше — вопрос уже с полным знанием: есть заранее известная последовательность фигур, которые будут бросать, сколько продержится игрок? Вот эта задача — уже NP-трудная.

        Но это всё при случайных фигурах. А в настоящем Тетрисе там PRNG… вовсе не факт, что тут AI должен вообще проиграть!
      +3
      Статью до конца не осилил, но очень люблю, когда люди вот так вот упарываются, приятно посмотреть!
        0

        Не могу представить какая могла бы быть восьмая фигура? По моему всё что можно уже есть, а остальные либо требуют больше-меньше четырех блоков или не были бы целыми.

        0
        «Аббревиатура V/O на экране юридической информации» из статьи — это скорее всего «внешнеэкономическое объединение», форма юр. лица, только таким разрешалось заключать контракты с иностранными структурами в то время.
          0
          Спасибо за подробный разбор

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

          Самое читаемое