Pull to refresh

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

Reading time 14 min
Views 16K
Глава первая, Глава вторая, Глава третья, Глава четвертая, Глава пятая, Эпилог

Disclaimer

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

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


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

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

То есть наличие объектов в комнате как-то влияет на наличие/отсутствие двойника. Со стражниками все ясно — их расстановка прописана прямо в заголовке уровня, а вот про двойника нет ни байта информации. Массивы данных для 4, 5 и 6 уровней ничем не отличались от всех остальных по своей сути. Полностью скопированный пятый уровень, скажем, в первый, не «вызывал» двойника из недр кода движка, а значит это как-то вшито в сам движок. Надо было понять, что меняется, если мы входим в комнату с двойником.

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

Наличие дополнительного персонажа (стражник, скелет или двойник) в комнате в NES версии вызывает замедление работы движка, и на эту особенность стоит обратить внимание.
Запускаем игру, ставим в $0401 единицу и действительно начинаем наблюдать замедление работы движка. Более того, нас снова начинает «атаковать» двойник.
Раз ячейка $0401 отвечает за наличие/отсутствие оного, то идем в отладчик, задаем точку останова:

В поле 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» и запустить игру в эмуляторе. Все как было — так и осталось.

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

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


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


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

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

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

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

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

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

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

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

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

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


FIN

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

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

PS

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

Прикладываю ссылку, по которой можно загрузить архив с редактором, небольшой документацией по игре и исходному коду патча.
Tags:
Hubs:
Total votes 86: ↑86 and ↓0 +86
Comments 11
Comments Comments 11

Articles