[NES] Пишем редактор уровней для Prince of Persia. Глава пятая. Отражение

в 21:09, , рубрики: diy или сделай сам, emulation, hacking, Nes, Игровые приставки, метки: , ,

Глава первая, Глава вторая, Глава третья, Глава четвертая, Глава пятая

Disclaimer

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

В ночь с пятницы на субботу я снова открыл отладчик, RAM Filter и начал искать…

Появление на свет

Началось все с того, что мне прислали модификацию игры, сделанную в редакторе, с вопросом: Почему виснет при входе в одну из комнат?
Комната выглядела в редакторе вполне обычно. Проверка правильности работы редактора показала, что все в порядке: что попросили, то и сохранил. Я попробовал переместить объекты, которые поставил автор модификации в комнате, и обнаружил, что зависание пропало, но в комнате появился двойник, который, к тому же, еще и имел наглость атаковать:
[NES] Пишем редактор уровней для Prince of Persia. Глава пятая. Отражение
То есть наличие объектов в комнате как-то влияет на наличие/отсутствие двойника. Со стражниками все ясно — их расстановка прописана прямо в заголовке уровня, а вот про двойника нет ни байта информации. Массивы данных для 4, 5 и 6 уровней ничем не отличались от всех остальных по своей сути. Полностью скопированный пятый уровень, скажем, в первый, не «вызывал» двойника из недр кода движка, а значит это как-то вшито в сам движок. Надо было понять, что меняется, если мы входим в комнату с двойником.

Я начал изучать пятый уровень, так как там двойник выполнял больше всего действий: если нажать на кнопку, которая открывает выход, он появлялся, выпивал драгоценное содержимое бутылки, и убегал. RAM Filter выявил аномальную активность при появлении двойника в памяти в районе адресов $0400-$0410: при входе в комнату взводился флаг в ячейке $0401, после нажатия на кнопку дополнительно взводился флаг в ячейке $0402, а затем, после того как тот убегал за пределы комнаты, ячейка $0401 обнулялась и больше не менялась. Значит, будем изучать, что тут происходит.

Наличие или отсутствие дополнительного персонажа (стражник, скелет или двойник) в комнате в NES версии вызывает замедление работы движка, и на эту особенность стоит обратить внимание.
Запускаем игру, ставим в $0401 единицу и действительно начинаем наблюдать замедление работы движка. Более того, нас снова начинает «атаковать» двойник.
Раз ячейка $0401 отвечает за наличие/отсутствие оного, то идем в отладчик, задаем точку останова:
[NES] Пишем редактор уровней для Prince of Persia. Глава пятая. Отражение
В поле Condition задаем условие: аккумулятор при записи в ячейку не должен содержать 0. Изменение ячейки может происходить и через индексные регистры, но зачастую это производится именно через аккумулятор. Останов приводит нас сюда:

$A1D0:A9 00	LDA #$00
$A1D2:8D 01 04	STA $0401 = #$00
$A1D5:A5 51	LDA $0051 = #$17
$A1D7:8D DE 04	STA $04DE = #$17
$A1DA:AD 02 04	LDA $0402 = #$00
$A1DD:D0 4D	BNE $A22C
$A1DF:A9 3D	LDA #$3D
$A1E1:85 2F	STA $002F = #$2D
$A1E3:A9 A2	LDA #$A2
$A1E5:85 30	STA $0030 = #$A2
$A1E7:A5 70	LDA $0070 = #$04
$A1E9:C9 05	CMP #$05
$A1EB:D0 04	BNE $A1F1
$A1ED:A5 51	LDA $0051 = #$17
$A1EF:F0 2D	BEQ $A21E
$A1F1:A9 4D	LDA #$4D
$A1F3:85 2F	STA $002F = #$2D
$A1F5:A9 A2	LDA #$A2
$A1F7:85 30	STA $0030 = #$A2
$A1F9:AD FD 04	LDA $04FD = #$00
$A1FC:D0 0C	BNE $A20A
$A1FE:A5 70	LDA $0070 = #$04
$A200:C9 03	CMP #$03
$A202:D0 06	BNE $A20A
$A204:A5 51	LDA $0051 = #$17
$A206:C9 03	CMP #$03
$A208:F0 14	BEQ $A21E
$A20A:A9 2D	LDA #$2D
$A20C:85 2F	STA $002F = #$2D
$A20E:A9 A2	LDA #$A2
$A210:85 30	STA $0030 = #$A2
$A212:A5 70	LDA $0070 = #$04
$A214:C9 04	CMP #$04
$A216:D0 14	BNE $A22C
$A218:A5 51	LDA $0051 = #$17
$A21A:C9 17	CMP #$17
$A21C:D0 0E	BNE $A22C
$A21E:A9 01	LDA #$01
$A220:8D 01 04	STA $0401 = #$00      ;; <<< останов
$A223:8D E0 06	STA $06E0 = #$00
$A226:20 AD F2	JSR $F2AD
$A229:20 85 DB	JSR $DB85
$A22C:60	RTS

Эта простыня довольно сложна для понимания, поэтому переведу ее в псевдокод, обозвав ячейки следующим образом:
— $70 — LEVEL;
— $51 — ROOM;
— $401 — MIRROR_FLAG.
С остальными позже разберемся:

char sub_A1D0()
{
	MIRROR_FLAG = 0;
	$04DE = ROOM;
	if ( $0402 ) goto label_A22C;
	$2F = #3D; $30 = #A2;

	if ( LEVEL != #05 ) goto label_A1F1;
	if ( !ROOM ) goto label_A21E;

label_A1F1:
	$2F = #4D; $30 = #A2;

	if ( $04FD ) goto label_A20A;
	if ( LEVEL != #03 ) goto label_A20A;
	if ( ROOM == #03 ) goto label_A21E;

label_A20A:
	$2F = #2D; $30 = #A2;
	
	if ( LEVEL != #04 ) goto label_A22C;
	if ( ROOM != #17 ) goto label_A22C;

label_A21E:
	MIRROR_FLAG = $06E0 = #01;
	sub_F2AD();
	sub_DB85();

label_A22C:
	return;
}

Теперь это проще изучить. Как видим, тут перебираются аккурат те уровни и комнаты в них, где появляется двойник, устанавливается флаг его наличия и вызываются еще две процедуры. Приводить я их не буду, лишь опишу то, что они делают.
— $F2AD — выполняет поиск удвоенного маркера #FF, начиная с адреса $060E, затем возвращает длину последовательности байт между $060E и маркером #FFFF (не включая последний) в регистре Y;
— $DB85 — выполняет сдвиг последовательности байт, начиная с найденного предыдущей процедурой маркера #FFFF, вперед, на длину последовательности, адрес которой хранится в ячейках $2F:$30, и которая также заканчивается маркером #FFFF.
В ячейки $2F и $30, как мы видим, вносятся жестко заданные адреса где-то в ROM: $A22D, $A23D, $A24D.

Простого внесения единицы в ячейку $0401 недостаточно, надо еще сделать некие магические действия.

Двое из ларца, одинаковых с лица

Так как двойник появляется только тогда, когда взводится флаг в ячейке $0402, то поищем сперва, где производится запись в нее, а затем, где читается значение из нее. Взводится флаг, как и следовало ожидать, тогда, когда мы нажимаем на кнопку, открывающую выход (там ничего интересного). А вот чтение из ячейки производится тут:

$A319:A9 89	LDA #$89
$A31B:85 72	STA $0072 = #$89
$A31D:A9 A3	LDA #$A3
$A31F:85 73	STA $0073 = #$A3
$A321:AD 02 04	LDA $0402 = #$01        ;; << останов
$A324:F0 53	BEQ $A379
$A326:AD 01 04	LDA $0401 = #$01
$A329:F0 4E	BEQ $A379
$A32B:EE 04 04	INC $0404 = #$00
$A32E:AC 03 04	LDY $0403 = #$00
$A331:B1 72	LDA ($72),Y @ $A389 = #$15
$A333:C9 FF	CMP #$FF
$A335:F0 43     BEQ $A37A
$A337:CD 04 04  CMP $0404 = #$01
$A33A:D0 0B     BNE $A347
$A33C:A9 00     LDA #$00
$A33E:8D 04 04  STA $0404 = #$01
$A341:EE 03 04  INC $0403 = #$00
$A344:EE 03 04  INC $0403 = #$00
$A347:C8        INY
$A348:B1 72     LDA ($72),Y @ $A389 = #$15
;; ...

Примечательная процедура, не правда ли? Суть ее действий, если изучить ее целиком, очень напоминает процедуру, которая играет за нас в demo play. В ячейках $72:$73 у нас адрес $A389:
15 01 06 0D 02 02 03 32 0C 4E 02 05 17 01 FF
Тот же маркер #FF, те же структуры, состоящие из двух байт, где первый из них — время, а второй… нет, второй — уже не имитация геймпада, а что-то иное. Когда отсчитываемое в ячейке $0404 значение сравнивается с первым байтом структуры, счетчик в ячейке $0403 увеличивается на 2 и процесс повторяется. Если посмотреть, что происходит во время этого процесса со вторым байтом, то мы придем к некоему массиву указателей:
0x15602: 00 00 AE 96 DB 96 64 97 9D 97 ...
Индексом в этом массиве как раз будет служить наш второй байт. Каждый указатель в этом массиве указывает на структуру, с которой движок работает довольно хитро. Если в этой структуре попадается значение, которое больше некоторого числа, то оно декодируется в индекс, который используется в массиве указателей на определенные процедуры в коде. Если же число меньше некоторого числа, то оно используется как аргумент для вышеобозначенных процедур. Процедуры эти, выполняя определенные действия, вносят следующий указатель в структуру, отвечающую за персонажа. Таким образом, после того, как в структуре персонажа будет задан некий стартовый указатель, начинает выполняться саморегулирующийся цикл, который приводит спрайт в движение. Например, если мы запишем в структуру указатель действия «бег», то каждый шаг бега будет инициироваться предыдущим, задавая новый указатель в этой же структуре в поле ActionPtr во время каждой итерации. Кроме того, в этих процедурах будет производиться перемещение спрайта на экране и озвучивание его действий.

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

Полный код (в псевдокоде) 'привода' двойника

char sub_A25D()
{
	if ( !MIRROR_FLAG ) goto label_A277;
	$06E0 = MIRROR_FLAG;
	if ( level == #03 ) goto label_A28D;

	if ( level != #04 ) goto label_A272;
	goto label_A319;

label_A272:
	// here CMP #05
	goto label_A398;

label_A277:
	return;

label_A278:
	$0072 = #86;
	$0073 = #A3;
	$04FE = #00;
	MIRROR.Y.LOW = #40;
	return sub_A326();

label_A28D:
	$04FB = $0402 = #00;
	MIRROR.DIRECTION = #FF;
	if ( room != #03 ) goto label_A2CF;

	if ( $04FD ) goto label_A278;

	if ( PRINCE.Y.LOW >= #48 ) goto label_A2CF;	
	$04FB = $04FC;	// if prince around mirror (by Y pos)
	if ( !$04FB ) goto label_A2C3;

	if ( PRINCE.X.HI ) goto label_A2C3;
	if ( PRINCE.X.LOW <= #AC ) goto label_A2D0;

label_A2C3:
	A = #02;
	Y = #0E;
	sub_CAFD();
	MIRROR.X.HI = #02;

label_A2CF:
	return;	

label_A2D0:
	X = #98;
	if ( !PRINCE.DIRECTION ) goto label_A2D9;
	X = #94;

label_A2D9:
	MIRROR.X.LOW = X;
	MIRROR.X.HI = #00;
	MIRROR.Y.LOW = PRINCE.Y.LOW;
	if ( PRINCE.ACTION_INDEX == #06 ) goto label_A2C3;
	if ( PRINCE.POSE_INDEX <= #06 ) goto label_A2F9;
	if ( PRINCE.POSE_INDEX <= #0E ) goto label_A301;

label_A2F9:
	if ( PRINCE.POSE_INDEX <= #20 ) goto label_A302;
	if ( PRINCE.POSE_INDEX >= #28 ) goto label_A302;

label_A301:
	return;

label_A302:
	X = #00;
	Y = #05;

label_A306:
	MIRROR.ACTION_PTR = PRINCE.ACTION_PTR;
	X++;
	Y--;
	if ( Y ) goto label_A306;

	MIRROR.DIRECTION = PRINCE.DIRECTION xor #FF;
	return;

label_A319:
	$0072 = #89;
	$0073 = #A3;
	if ( !$0402 ) goto label_A379;
	
label_A326:
	if ( !$0401 ) goto label_A379;
	$404++;
	if ( $72[Y] == #FF ) goto label_A37A;
	if ( $0404 ) goto label_A347;

	$0404 = #00;
	$0403 += 2;

label_A347:
	Y++;
	
	A = $72[Y];
	Y = #0E;
	sub_CAFD();
	if ( MIRROR.POSE_INDEX != #6D ) goto label_A379;
	$0054 = $06F0 = #00;
	$04B1 = #03;
	sub_DB23();
	A = #27;
	sub_F203();
	#0610[X] = #02;
	A = #20;
	sub_F203();
	#0610[X] = #02;

label_A379:
	return;

label_A37A:
	$060E = $061C = $0401 = #00;
	return;

A386:
	.data[XX:YY],1C:01 FF // XX:time, YY:action, FF:EOF

A389:
	.data[XX:YY],15:01 06:0D 02:02 03:32 0C:4E 02:05 17:01 FF

label_A398:
	if ( level == #05 && !$610 ) goto label_A3A7;
	if ( PRINCE.X.LOW <= #10 ) goto label_A3A7;
	goto label_A3D1;

label_A3A7:
	$0054 = #00;
	$04B1 = #0C;
	if ( !sub_DB18() ) goto label_A3D1;
	
	$0072 = #D2;
	$0073 = #A3;
	sub_A326();
	if ( MIRROR.POSE_INDEX != #7C ) goto label_A3D1;
	$06FC = #00;
	$06FB = #0B;

label_A3D1:
	return;	

A3D2:
	.data[XX:YY]: 04:02 19:2A F0:02 F0:02 F0:02 FF

}

char sub_F203()
{
	$0017 = A;
	switch_bank(#02);
	sub_B298();
	$04BF = Y;
	switch_bank($06D1);
	Y = $04BF;
	return;
}

char sub_B298()
{
	X=#00;

label_B29A:	// aka sub_B29A
	Y=#00;
	if ( #060E[X] != #FF ) goto label_B2A4;
	Y++;

label_B2A4:
	if ( #060E[X] & #7F == $0017 ) goto label_B2BC;
	if ( #060F[X] != #FF ) goto label_B2B2;
	Y++;

label_B2B2:
	if ( Y == #02 ) goto label_B2BF;
	sub_F215();
	goto label_B29A;

label_B2BC:
	Y = #01;
	return;

label_B2BF:
	Y = #00;
	return;
}

char sub_8730()
{
	if ( A == #0616[Y] ) goto label_874B; // $0616+Y - address of MIRROR.ACTION_INDEX
	$0616[Y] = A;
	X = A << 1;
	#0613[Y] = #95F2[X];            // set MIRROR.ACTION_PTR to new value ($0613 + Y - address of ACTION_PTR in MIRROR struct)
	#0614[Y] = #95F3[X];
	#0618[Y] = #FF;                    // set EOF marker

label_874B:
	return;
}

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

Делаем патч

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

Для управления двойником нам достаточно будет скопировать его структуру после структуры самого принца и выставить флаг $0401. В дальнейшем мы будем задавать действия, которые он будет выполнять, записывая указатель в его структуру. Но как это сделать? Нужно написать код. Но куда его вставить? В игре практически нет свободных мест, а те небольшие огрызки по несколько байт, которые остались между кодом и данными, использовать невозможно. Значит, надо изыскивать дополнительное место иными способами.

Шире круг

Как мы помним, Mapper #02 содержит в себе два различных вида маппинга. Один из них — UNROM, — содержит в себе 8 банков PRG-ROM, а второй — UOROM, — 16. Если вставить в ROM-файл еще 8x16 кБ, то маппер изменится на UOROM без вреда для игры. Вставить, однако, надо так, чтобы последний банк так и остался последним, а первые 7 должны остаться в начале.

Лезем в шестнадцатеричный редактор, меняем в заголовке число банков (пятый по счету байт, смещение 0x04) с #08 на #10, затем вставляем в файл 8x16 кБ нулей, начиная со смещения 0x1C010. Размер ROM-файла изменится и станет равным 262 160 байт. Запустим полученный ROM в эмуляторе… Работает!
Если мы будем выполнять эту процедуру в «железе», то нам потребуется поменять контроллер маппера, а ROM-память поставить увеличенную — 32x8 кБ, и мы также получим работающую игру.

ROM увеличили, место есть, но как им воспользоваться? Для того, чтобы вызвать код или прочитать оттуда данные, нам надо включить этот банк и передать туда управление. Сделать это можно безопасно только из последнего банка, но в нем нет места. Куда же писать код?
Зададимся требованиями:

  • Код должен вызываться из основного цикла игры, так как мы будем управлять двойником прямо во время основной игры;
  • Код должен вызываться и возвращать управление в последний банк;
  • Вызываемый код не должен нарушать работу оригинального кода.

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

;; ...
$CC3B:20 00 CB	JSR $CB00
$CC3E:20 12 9F	JSR $9F12
$CC41:20 DD A3	JSR $A3DD
$CC44:4C 1A CC	JMP $CC1A

Процедуры $9F12, $A3DD трогать нельзя, так как они в другом банке, на месте которого должен быть наш. Перенести их туда тоже нельзя, так как они потянут за собой все остальное содержимое банка. Можно, однако, поменять адрес $CB00 на адрес новой процедуры, которая будет включать наш банк #07, вызывать наш код, затем вызывать оригинальную процедуру $CB00 и возвращать управление обратно в цикл. Код примерно такой:

LDA #07
JSR $F2D3			;; включаем банк #07
JSR $8000			;; вызываем наш код, который будет в начале банка
JMP $CB00			;; вызываем оригинальную процедуру $CB00, 
					;; которая сама инструкцией RTS вернет управление в основной цикл

12 байт (по 3 байта на каждую инструкцию). Немного, но их надо куда-то вставить, а места и так нет. Немного места можно выиграть путем модификации существующего кода в последнем банке. Достаточно взять какую-нибудь длинную процедуру, которая не использует данные из банков с #00 по #06, которая так же не вызывает процедур, использующих эти банки, и которая вызывается из процедур, которые не используют эти банки. После того, как мы ее найдем, мы сможем перенести ее в новый банк, на ее месте разместить наш код, а перед нашим кодом поместим вызов страдалицы из нового банка.

Долго ли, коротко ли, такая процедура была найдена:

$C111:AD C6 06	LDA $06C6 = #$00
$C114:C9 06	CMP #$06
$C116:B0 20	BCS $C138
$C118:AD D7 06	LDA $06D7 = #$04
$C11B:D0 1B	BNE $C138
$C11D:8D 30 07	STA $0730 = #$00
$C120:85 54	STA $0054 = #$01
$C122:8D F0 06	STA $06F0 = #$00
$C125:AD 17 06	LDA $0617 = #$0C
$C128:C9 11	CMP #$11
$C12A:90 0D	BCC $C139
$C12C:C9 2B	CMP #$2B
$C12E:B0 09	BCS $C139
$C130:C9 1A	CMP #$1A
$C132:90 04	BCC $C138
$C134:C9 26	CMP #$26
$C136:90 01	BCC $C139
$C138:60	RTS

Что она делает — вопрос открытый, но впрочем это и не важно. Главное, что она соответствует всем необходимым требованиям: не вызывается из «динамических» банков, сама не вызывает код из них и не обращается к ним. А переход по адресу $C139 мы немного переделаем.
Немного модифицируем наш код включения банка и поместим его по адресу $C111:

$C111:20 17 C1	JSR $C117		;; вызываем код включения банка
$C114:4C 90 BF	JMP $BF90		;; и вызываем оригинальную процедуру (она теперь живет по адресу $BF90)
										;; потом она инструкцией RTS вернет управление вызвавшему коду сама

;;	======== процедура включения нашего банка ==========
$C117:A9 07	LDA #$07
$C119:4C D3 F2	JMP $F2D3
;;	======== конец процедуры включения нашего банка ==========

										;; а теперь код, вызывающий наш патч
$C11C:20 17 C1	JSR $C117		;; включаем банк
$C11F:20 10 B0	JSR $B010		;; вызываем наш патч. Начало нашего кода - $B010.
$C122:4C 00 CB	JMP $CB00		;; вызываем оригинальную $CB00, которая вернет нас обратно в цикл
$C125:00	BRK
;; ...
$C138:00	BRK

Мало того, что мы успешно впихнули вызов нашего кода, так у нас еще и осталось уйма места с $C125 по $C138, которое мы можем использовать как-нибудь еще: ведь у нас есть еще целых 7(!) свободных банков, а с ними тоже надо будет работать из последнего банка (если мы их будем в будущем использовать). Адрес нового кода я разместил по адресу $B010 (примерно середина банка), так как нам придется размещать копию уровней и комнат в нашем банке, плюс еще кое-какие данные. Но об этом чуть ниже.

Модифицируем и саму процедуру, так как в ней есть переходы на адрес $C139, который за ее пределами:

$BF90:AD C6 06		LDA $06C6 = #$00

;; ============ CUT ============

$BFAA:90 0D		BCC $BFB9
$BFAC:C9 2B		CMP #$2B
$BFAE:B0 09		BCS $BFB9
$BFB0:C9 1A		CMP #$1A
$BFB2:90 04		BCC $BFB8
$BFB4:C9 26		CMP #$26
$BFB6:90 01		BCC $BFB9
$BFB8:60		RTS
$BFB9:4C 39 C1		JMP $C139

Все! То, что когда-то переходило на адрес $C139 теперь переходит на адрес $BFB9, по которому инструкция JMP заставляет прыгнуть на $C139, словно мы никуда и не перемещали код.
Тело основного цикла теперь мы можем поменять на следующее:

;; ...
$CC3B:20 00 CB	JSR $C11С
$CC3E:20 12 9F	JSR $9F12
$CC41:20 DD A3	JSR $A3DD
$CC44:4C 1A CC	JMP $CC1A

Можем разместить по адресу $B010 какую-нибудь пустышку вроде «RTS» и запустить игру в эмуляторе. Все как было — так и осталось.

Пишем свой «привод» для «отражения»

Осталось только разработать свой алгоритм появления отражения в комнате.
Я разработал следующую структуру данных:
[NES] Пишем редактор уровней для Prince of Persia. Глава пятая. Отражение

При входе в комнату, мы по номеру уровня извлекаем указатель в первом массиве указателей (Levels ptrs), если он не нулевой, то переходим ко второму массиву (Rooms ptrs).
Если извлеченный по номеру комнаты указатель также не нулевой, то приступаем к чтению структуры отражения.
Структура отражения выглядит следующим образом:

  • Структура, описывающая начальное состояние персонажа (struct CHARACTER);
  • Пары «время»:«действие», которые описывают, что будет делать персонаж и в какой интервал времени;
  • Маркер окончания #FF.

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

Реагируем на события

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

Начиная с адреса $0500 в памяти у нас лежат данные, которые определяют те или иные изменения в комнатах. Например, если персонаж выпьет зелье из бутылки, то бутылка там больше не появится. Либо, если упадет плита, то в этом массиве будет хранится как то, что теперь на ее месте дырка, так и то, что на том месте, куда она упала — ее осколки. То же самое с открытыми и закрытыми решетками. Каждое такое событие кодируется двумя битами. В комнате у нас 30 блоков, на каждую строку из 10 блоков приходится по 3 байта (4 блока умещается в 1 байте, причем в третьем байте оставшиеся 4 бита остаются неиспользованными), итого по 9 байт на комнату. На уровень, таким образом, выходит 9*24 = 216 байт.

Как только произошло какое-нибудь действие, соответствующая пара бит в этом массиве устанавливается в определенное значение. Всего возможных комбинаций — 3 (00 — означает, что ничего не происходило), а событий много: решетка открылась, решетка закрылась, выпили из бутылки, плита отсутствует (упала), осколки упавшей плиты; соответственно события перекрываются. Например, если мы поставим бутылку, а над ней повесим падающую плиту, то после ее падения мы либо недосчитаемся бутылки, либо не увидим осколков упавшей плиты.

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

Заставляем нажать кнопку

Ну и напоследок остается научить его что-либо делать. Двойник «из коробки» не умеет делать ровным счетом ничего. Умеет лишь появляться на экране и совершать какие-либо движения, мозоля глаза. Контактировать со стенами он не умеет, нажимать на кнопки не умеет, да и вообще он ничего не умеет.

Пока наш принц что-то делает в игре, его координаты постоянно сравниваются с блоком, в котором он находится, и если этот блок «активный», то предпринимаются определенные действия: например, если мы попали в блок «Кнопка», то эта кнопка будет нажиматься. Двойник же бегает сам по себе и никто за его передвижениями не следит. Значит, мы в своем патче обязаны это сделать за основной движок. Для этого достаточно передать в основной движок (в процедуру проверки) координату двойника вместо координаты принца, а дальше все произойдет само. Но проблема в том, что движок сравнивает блок с массивом данных комнаты, который хранится в другом банке. Тем не менее, в нашем банке еще достаточно места, а во время проверки будет включен именно он, поэтому мы… просто скопируем игровые данные из оригинального банка в наш на то же место, и движок будет считывать эти данные как ни в чем не бывало (помните, ранее мы разместили код в середине банка?). Во время редактирования, правда, теперь надо будет учесть, что изменения следует вносить в основную копию и в резервную.

Результат налицо:

FIN

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

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

PS

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

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

Автор: loginsin

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js